diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 1bd167291..9e029e8e5 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -581,6 +581,10 @@ export default function ChatView({ threadId }: ChatViewProps) { const syncServerReadModel = useStore((store) => store.syncServerReadModel); const setStoreThreadError = useStore((store) => store.setError); const setStoreThreadBranch = useStore((store) => store.setThreadBranch); + const markStoreThreadOptimisticUserSend = useStore((store) => store.markThreadOptimisticUserSend); + const clearStoreThreadOptimisticUserSend = useStore( + (store) => store.clearThreadOptimisticUserSend, + ); const { settings } = useAppSettings(); const navigate = useNavigate(); const rawSearch = useSearch({ @@ -2630,6 +2634,7 @@ export default function ChatView({ threadId }: ChatViewProps) { sizeBytes: image.sizeBytes, previewUrl: image.previewUrl, })); + markStoreThreadOptimisticUserSend(threadIdForSend, messageCreatedAt); setOptimisticUserMessages((existing) => [ ...existing, { @@ -2792,6 +2797,7 @@ export default function ChatView({ threadId }: ChatViewProps) { clearDraftThread(threadIdForSend); } })().catch(async (err: unknown) => { + clearStoreThreadOptimisticUserSend(threadIdForSend); if (createdServerThreadForLocalDraft && !turnStartSucceeded) { await api.orchestration .dispatchCommand({ diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 6a07a667a..83827a6eb 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -84,6 +84,7 @@ import { useThreadSelectionStore } from "../threadSelectionStore"; import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; import { isNonEmpty as isNonEmptyString } from "effect/String"; import { resolveThreadStatusPill, shouldClearThreadSelectionOnMouseDown } from "./Sidebar.logic"; +import { compareThreadsForSidebar } from "../lib/threadRecency"; const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; const THREAD_PREVIEW_LIMIT = 6; @@ -255,6 +256,7 @@ function SortableProjectItem({ export default function Sidebar() { const projects = useStore((store) => store.projects); const threads = useStore((store) => store.threads); + const optimisticUserSendAtByThreadId = useStore((store) => store.optimisticUserSendAtByThreadId); const markThreadUnread = useStore((store) => store.markThreadUnread); const toggleProject = useStore((store) => store.toggleProject); const reorderProjects = useStore((store) => store.reorderProjects); @@ -476,11 +478,7 @@ export default function Sidebar() { (projectId: ProjectId) => { const latestThread = threads .filter((thread) => thread.projectId === projectId) - .toSorted((a, b) => { - const byDate = new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); - if (byDate !== 0) return byDate; - return b.id.localeCompare(a.id); - })[0]; + .toSorted((a, b) => compareThreadsForSidebar(a, b, optimisticUserSendAtByThreadId))[0]; if (!latestThread) return; void navigate({ @@ -488,7 +486,7 @@ export default function Sidebar() { params: { threadId: latestThread.id }, }); }, - [navigate, threads], + [navigate, optimisticUserSendAtByThreadId, threads], ); const addProjectFromPath = useCallback( @@ -1415,12 +1413,9 @@ export default function Sidebar() { {projects.map((project) => { const projectThreads = threads .filter((thread) => thread.projectId === project.id) - .toSorted((a, b) => { - const byDate = - new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); - if (byDate !== 0) return byDate; - return b.id.localeCompare(a.id); - }); + .toSorted((a, b) => + compareThreadsForSidebar(a, b, optimisticUserSendAtByThreadId), + ); const isThreadListExpanded = expandedThreadListsByProject.has(project.id); const hasHiddenThreads = projectThreads.length > THREAD_PREVIEW_LIMIT; const visibleThreads = diff --git a/apps/web/src/lib/threadRecency.test.ts b/apps/web/src/lib/threadRecency.test.ts new file mode 100644 index 000000000..0172ea543 --- /dev/null +++ b/apps/web/src/lib/threadRecency.test.ts @@ -0,0 +1,170 @@ +import { ProjectId, ThreadId } from "@t3tools/contracts"; +import { describe, expect, it } from "vitest"; + +import { + compareThreadsForSidebar, + getLatestUserMessageAt, + getThreadSidebarRecency, +} from "./threadRecency"; +import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type Thread } from "../types"; + +function makeThread(overrides: Partial = {}): Thread { + return { + id: ThreadId.makeUnsafe("thread-1"), + codexThreadId: null, + projectId: ProjectId.makeUnsafe("project-1"), + title: "Thread", + model: "gpt-5-codex", + runtimeMode: DEFAULT_RUNTIME_MODE, + interactionMode: DEFAULT_INTERACTION_MODE, + session: null, + messages: [], + turnDiffSummaries: [], + activities: [], + proposedPlans: [], + error: null, + createdAt: "2026-03-10T10:00:00.000Z", + latestTurn: null, + branch: null, + worktreePath: null, + ...overrides, + }; +} + +describe("threadRecency", () => { + it("returns the latest user message timestamp", () => { + const thread = makeThread({ + messages: [ + { + id: "assistant-1" as never, + role: "assistant", + text: "assistant", + createdAt: "2026-03-10T10:10:00.000Z", + streaming: false, + }, + { + id: "user-1" as never, + role: "user", + text: "first", + createdAt: "2026-03-10T10:05:00.000Z", + streaming: false, + }, + { + id: "user-2" as never, + role: "user", + text: "latest", + createdAt: "2026-03-10T10:15:00.000Z", + streaming: false, + }, + ], + }); + + expect(getLatestUserMessageAt(thread)).toBe("2026-03-10T10:15:00.000Z"); + }); + + it("ignores assistant-only activity when deriving recency", () => { + const thread = makeThread({ + messages: [ + { + id: "assistant-1" as never, + role: "assistant", + text: "assistant", + createdAt: "2026-03-10T10:20:00.000Z", + streaming: false, + }, + ], + }); + + expect(getThreadSidebarRecency(thread)).toBe("2026-03-10T10:00:00.000Z"); + }); + + it("prefers optimistic recency over confirmed user-message recency", () => { + const thread = makeThread({ + messages: [ + { + id: "user-1" as never, + role: "user", + text: "first", + createdAt: "2026-03-10T10:05:00.000Z", + streaming: false, + }, + ], + }); + + expect(getThreadSidebarRecency(thread, "2026-03-10T10:06:00.000Z")).toBe( + "2026-03-10T10:06:00.000Z", + ); + }); + + it("does not allow an older optimistic timestamp to lower confirmed recency", () => { + const thread = makeThread({ + messages: [ + { + id: "user-1" as never, + role: "user", + text: "newest", + createdAt: "2026-03-10T10:07:00.000Z", + streaming: false, + }, + ], + }); + + expect(getThreadSidebarRecency(thread, "2026-03-10T10:06:00.000Z")).toBe( + "2026-03-10T10:07:00.000Z", + ); + }); + + it("sorts by sidebar recency with deterministic tiebreakers", () => { + const optimisticThread = makeThread({ + id: ThreadId.makeUnsafe("thread-optimistic"), + createdAt: "2026-03-10T09:00:00.000Z", + messages: [ + { + id: "user-1" as never, + role: "user", + text: "older", + createdAt: "2026-03-10T10:01:00.000Z", + streaming: false, + }, + ], + }); + const newerCreatedThread = makeThread({ + id: ThreadId.makeUnsafe("thread-newer-created"), + createdAt: "2026-03-10T10:00:00.000Z", + messages: [ + { + id: "user-2" as never, + role: "user", + text: "same time", + createdAt: "2026-03-10T10:02:00.000Z", + streaming: false, + }, + ], + }); + const olderCreatedThread = makeThread({ + id: ThreadId.makeUnsafe("thread-older-created"), + createdAt: "2026-03-10T08:00:00.000Z", + messages: [ + { + id: "user-3" as never, + role: "user", + text: "same time", + createdAt: "2026-03-10T10:02:00.000Z", + streaming: false, + }, + ], + }); + + const ordered = [olderCreatedThread, newerCreatedThread, optimisticThread].toSorted((a, b) => + compareThreadsForSidebar(a, b, { + [optimisticThread.id]: "2026-03-10T10:03:00.000Z", + }), + ); + + expect(ordered.map((thread) => thread.id)).toEqual([ + ThreadId.makeUnsafe("thread-optimistic"), + ThreadId.makeUnsafe("thread-newer-created"), + ThreadId.makeUnsafe("thread-older-created"), + ]); + }); +}); diff --git a/apps/web/src/lib/threadRecency.ts b/apps/web/src/lib/threadRecency.ts new file mode 100644 index 000000000..555f21046 --- /dev/null +++ b/apps/web/src/lib/threadRecency.ts @@ -0,0 +1,41 @@ +import type { ThreadId } from "@t3tools/contracts"; + +import type { Thread } from "../types"; + +export type OptimisticUserSendAtByThreadId = Partial>; + +export function getLatestUserMessageAt(thread: Thread): string | null { + let latestUserMessageAt: string | null = null; + for (const message of thread.messages) { + if (message.role !== "user") continue; + if (latestUserMessageAt === null || message.createdAt.localeCompare(latestUserMessageAt) > 0) { + latestUserMessageAt = message.createdAt; + } + } + return latestUserMessageAt; +} + +export function getThreadSidebarRecency(thread: Thread, optimisticAt?: string | null): string { + const confirmedRecency = getLatestUserMessageAt(thread) ?? thread.createdAt; + if (optimisticAt == null) { + return confirmedRecency; + } + return optimisticAt.localeCompare(confirmedRecency) > 0 ? optimisticAt : confirmedRecency; +} + +export function compareThreadsForSidebar( + left: Thread, + right: Thread, + optimisticUserSendAtByThreadId: OptimisticUserSendAtByThreadId, +): number { + const byRecency = getThreadSidebarRecency( + right, + optimisticUserSendAtByThreadId[right.id], + ).localeCompare(getThreadSidebarRecency(left, optimisticUserSendAtByThreadId[left.id])); + if (byRecency !== 0) return byRecency; + + const byCreatedAt = right.createdAt.localeCompare(left.createdAt); + if (byCreatedAt !== 0) return byCreatedAt; + + return right.id.localeCompare(left.id); +} diff --git a/apps/web/src/store.test.ts b/apps/web/src/store.test.ts index 92d084f2d..f09216b74 100644 --- a/apps/web/src/store.test.ts +++ b/apps/web/src/store.test.ts @@ -7,7 +7,14 @@ import { } from "@t3tools/contracts"; import { describe, expect, it } from "vitest"; -import { markThreadUnread, reorderProjects, syncServerReadModel, type AppState } from "./store"; +import { + clearThreadOptimisticUserSend, + markThreadOptimisticUserSend, + markThreadUnread, + reorderProjects, + syncServerReadModel, + type AppState, +} from "./store"; import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type Thread } from "./types"; function makeThread(overrides: Partial = {}): Thread { @@ -47,6 +54,7 @@ function makeState(thread: Thread): AppState { ], threads: [thread], threadsHydrated: true, + optimisticUserSendAtByThreadId: {}, }; } @@ -182,12 +190,57 @@ describe("store pure functions", () => { ], threads: [], threadsHydrated: true, + optimisticUserSendAtByThreadId: {}, }; const next = reorderProjects(state, project1, project3); expect(next.projects.map((project) => project.id)).toEqual([project2, project3, project1]); }); + + it("markThreadOptimisticUserSend stores the optimistic timestamp", () => { + const state = makeState(makeThread()); + + const next = markThreadOptimisticUserSend( + state, + ThreadId.makeUnsafe("thread-1"), + "2026-03-10T12:00:00.000Z", + ); + + expect(next.optimisticUserSendAtByThreadId[ThreadId.makeUnsafe("thread-1")]).toBe( + "2026-03-10T12:00:00.000Z", + ); + }); + + it("markThreadOptimisticUserSend keeps the newest timestamp for a thread", () => { + const state = { + ...makeState(makeThread()), + optimisticUserSendAtByThreadId: { + [ThreadId.makeUnsafe("thread-1")]: "2026-03-10T12:00:01.000Z", + }, + } satisfies AppState; + + const next = markThreadOptimisticUserSend( + state, + ThreadId.makeUnsafe("thread-1"), + "2026-03-10T12:00:00.000Z", + ); + + expect(next).toBe(state); + }); + + it("clearThreadOptimisticUserSend removes the optimistic timestamp", () => { + const state = { + ...makeState(makeThread()), + optimisticUserSendAtByThreadId: { + [ThreadId.makeUnsafe("thread-1")]: "2026-03-10T12:00:00.000Z", + }, + } satisfies AppState; + + const next = clearThreadOptimisticUserSend(state, ThreadId.makeUnsafe("thread-1")); + + expect(next.optimisticUserSendAtByThreadId[ThreadId.makeUnsafe("thread-1")]).toBeUndefined(); + }); }); describe("store read model sync", () => { @@ -229,6 +282,7 @@ describe("store read model sync", () => { ], threads: [], threadsHydrated: true, + optimisticUserSendAtByThreadId: {}, }; const readModel: OrchestrationReadModel = { snapshotSequence: 2, @@ -257,4 +311,89 @@ describe("store read model sync", () => { expect(next.projects.map((project) => project.id)).toEqual([project2, project1, project3]); }); + + it("clears optimistic user send timestamps once the confirmed user message catches up", () => { + const initialState: AppState = { + ...makeState(makeThread()), + optimisticUserSendAtByThreadId: { + [ThreadId.makeUnsafe("thread-1")]: "2026-03-10T12:00:00.000Z", + }, + }; + const readModel = makeReadModel( + makeReadModelThread({ + messages: [ + { + id: "message-1" as never, + role: "user", + text: "Hello", + attachments: [], + turnId: null, + streaming: false, + createdAt: "2026-03-10T12:00:00.000Z", + updatedAt: "2026-03-10T12:00:00.000Z", + }, + ], + }), + ); + + const next = syncServerReadModel(initialState, readModel); + + expect(next.optimisticUserSendAtByThreadId[ThreadId.makeUnsafe("thread-1")]).toBeUndefined(); + }); + + it("keeps optimistic user send timestamps when the confirmed thread has not caught up", () => { + const initialState: AppState = { + ...makeState(makeThread()), + optimisticUserSendAtByThreadId: { + [ThreadId.makeUnsafe("thread-1")]: "2026-03-10T12:00:01.000Z", + }, + }; + const readModel = makeReadModel( + makeReadModelThread({ + messages: [ + { + id: "message-1" as never, + role: "user", + text: "Hello", + attachments: [], + turnId: null, + streaming: false, + createdAt: "2026-03-10T12:00:00.000Z", + updatedAt: "2026-03-10T12:00:00.000Z", + }, + ], + }), + ); + + const next = syncServerReadModel(initialState, readModel); + + expect(next.optimisticUserSendAtByThreadId[ThreadId.makeUnsafe("thread-1")]).toBe( + "2026-03-10T12:00:01.000Z", + ); + }); + + it("drops optimistic user send timestamps for threads missing from the latest snapshot", () => { + const initialState: AppState = { + ...makeState(makeThread()), + optimisticUserSendAtByThreadId: { + [ThreadId.makeUnsafe("thread-1")]: "2026-03-10T12:00:01.000Z", + }, + }; + const readModel: OrchestrationReadModel = { + snapshotSequence: 2, + updatedAt: "2026-02-27T00:00:00.000Z", + projects: [ + makeReadModelProject({ + id: ProjectId.makeUnsafe("project-1"), + title: "Project 1", + workspaceRoot: "/tmp/project-1", + }), + ], + threads: [], + }; + + const next = syncServerReadModel(initialState, readModel); + + expect(next.optimisticUserSendAtByThreadId).toEqual({}); + }); }); diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index faebe4b0f..a1faaef17 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -15,6 +15,7 @@ import { import { create } from "zustand"; import { type ChatMessage, type Project, type Thread } from "./types"; import { Debouncer } from "@tanstack/react-pacer"; +import { getLatestUserMessageAt, type OptimisticUserSendAtByThreadId } from "./lib/threadRecency"; // ── State ──────────────────────────────────────────────────────────── @@ -22,6 +23,7 @@ export interface AppState { projects: Project[]; threads: Thread[]; threadsHydrated: boolean; + optimisticUserSendAtByThreadId: OptimisticUserSendAtByThreadId; } const PERSISTED_STATE_KEY = "t3code:renderer-state:v8"; @@ -41,6 +43,7 @@ const initialState: AppState = { projects: [], threads: [], threadsHydrated: false, + optimisticUserSendAtByThreadId: {}, }; const persistedExpandedProjectCwds = new Set(); const persistedProjectOrderCwds: string[] = []; @@ -325,11 +328,26 @@ export function syncServerReadModel(state: AppState, readModel: OrchestrationRea activities: thread.activities.map((activity) => ({ ...activity })), }; }); + const threadById = new Map(threads.map((thread) => [thread.id, thread] as const)); + const optimisticUserSendAtByThreadId = Object.fromEntries( + Object.entries(state.optimisticUserSendAtByThreadId).filter(([threadId, optimisticAt]) => { + if (typeof optimisticAt !== "string") { + return false; + } + const thread = threadById.get(threadId as ThreadId); + if (!thread) { + return false; + } + const latestUserMessageAt = getLatestUserMessageAt(thread); + return latestUserMessageAt === null || latestUserMessageAt.localeCompare(optimisticAt) < 0; + }), + ) as OptimisticUserSendAtByThreadId; return { ...state, projects, threads, threadsHydrated: true, + optimisticUserSendAtByThreadId, }; } @@ -430,6 +448,36 @@ export function setThreadBranch( return threads === state.threads ? state : { ...state, threads }; } +export function markThreadOptimisticUserSend( + state: AppState, + threadId: ThreadId, + at?: string, +): AppState { + const nextAt = at ?? new Date().toISOString(); + const previousAt = state.optimisticUserSendAtByThreadId[threadId]; + if (previousAt !== undefined && previousAt.localeCompare(nextAt) >= 0) { + return state; + } + return { + ...state, + optimisticUserSendAtByThreadId: { + ...state.optimisticUserSendAtByThreadId, + [threadId]: nextAt, + }, + }; +} + +export function clearThreadOptimisticUserSend(state: AppState, threadId: ThreadId): AppState { + if (state.optimisticUserSendAtByThreadId[threadId] === undefined) { + return state; + } + const { [threadId]: _cleared, ...rest } = state.optimisticUserSendAtByThreadId; + return { + ...state, + optimisticUserSendAtByThreadId: rest as OptimisticUserSendAtByThreadId, + }; +} + // ── Zustand store ──────────────────────────────────────────────────── interface AppStore extends AppState { @@ -441,6 +489,8 @@ interface AppStore extends AppState { reorderProjects: (draggedProjectId: Project["id"], targetProjectId: Project["id"]) => void; setError: (threadId: ThreadId, error: string | null) => void; setThreadBranch: (threadId: ThreadId, branch: string | null, worktreePath: string | null) => void; + markThreadOptimisticUserSend: (threadId: ThreadId, at?: string) => void; + clearThreadOptimisticUserSend: (threadId: ThreadId) => void; } export const useStore = create((set) => ({ @@ -457,6 +507,10 @@ export const useStore = create((set) => ({ setError: (threadId, error) => set((state) => setError(state, threadId, error)), setThreadBranch: (threadId, branch, worktreePath) => set((state) => setThreadBranch(state, threadId, branch, worktreePath)), + markThreadOptimisticUserSend: (threadId, at) => + set((state) => markThreadOptimisticUserSend(state, threadId, at)), + clearThreadOptimisticUserSend: (threadId) => + set((state) => clearThreadOptimisticUserSend(state, threadId)), })); // Persist state changes with debouncing to avoid localStorage thrashing