From 45e5724c1bf41bf7407c1265bcd340629bf6601c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 29 Mar 2026 16:38:25 -0700 Subject: [PATCH 01/22] Optimize orchestration sync and sidebar thread snapshots - Apply orchestration events incrementally instead of resyncing full snapshots - Reduce store subscription churn with cached thread snapshots and selectors - Update store tests for incremental event handling --- apps/web/src/components/ChatView.tsx | 59 +- apps/web/src/components/Sidebar.logic.ts | 28 +- apps/web/src/components/Sidebar.tsx | 70 +- apps/web/src/routes/__root.tsx | 164 +++-- apps/web/src/store.test.ts | 247 ++++++- apps/web/src/store.ts | 876 ++++++++++++++++++++--- apps/web/src/types.ts | 1 + 7 files changed, 1269 insertions(+), 176 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index fbd332354a..b1e4672e1e 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -27,6 +27,7 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } fr import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useDebouncedValue } from "@tanstack/react-pacer"; import { useNavigate, useSearch } from "@tanstack/react-router"; +import { useShallow } from "zustand/react/shallow"; import { gitBranchesQueryOptions, gitCreateWorktreeMutationOptions } from "~/lib/gitReactQuery"; import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery"; import { serverConfigQueryOptions, serverQueryKeys } from "~/lib/serverReactQuery"; @@ -63,7 +64,7 @@ import { setPendingUserInputCustomAnswer, type PendingUserInputDraftAnswer, } from "../pendingUserInput"; -import { useStore } from "../store"; +import { selectProjectById, selectThreadById, useStore } from "../store"; import { buildPlanImplementationThreadTitle, buildPlanImplementationPrompt, @@ -76,8 +77,33 @@ import { DEFAULT_THREAD_TERMINAL_ID, MAX_TERMINALS_PER_GROUP, type ChatMessage, + type Thread, type TurnDiffSummary, } from "../types"; + +type ThreadPlanCatalogEntry = Pick; + +const threadPlanCatalogCache = new Map< + ThreadId, + { proposedPlans: Thread["proposedPlans"]; entry: ThreadPlanCatalogEntry } +>(); + +function toThreadPlanCatalogEntry(thread: Thread): ThreadPlanCatalogEntry { + const cached = threadPlanCatalogCache.get(thread.id); + if (cached && cached.proposedPlans === thread.proposedPlans) { + return cached.entry; + } + + const entry: ThreadPlanCatalogEntry = { + id: thread.id, + proposedPlans: thread.proposedPlans, + }; + threadPlanCatalogCache.set(thread.id, { + proposedPlans: thread.proposedPlans, + entry, + }); + return entry; +} import { basenameOfPath } from "../vscode-icons"; import { useTheme } from "../hooks/useTheme"; import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; @@ -246,10 +272,8 @@ interface PendingPullRequestSetupRequest { } export default function ChatView({ threadId }: ChatViewProps) { - const threads = useStore((store) => store.threads); - const projects = useStore((store) => store.projects); + const serverThread = useStore(selectThreadById(threadId)); const markThreadVisited = useStore((store) => store.markThreadVisited); - const syncServerReadModel = useStore((store) => store.syncServerReadModel); const setStoreThreadError = useStore((store) => store.setError); const setStoreThreadBranch = useStore((store) => store.setThreadBranch); const settings = useSettings(); @@ -466,8 +490,10 @@ export default function ChatView({ threadId }: ChatViewProps) { [composerTerminalContexts, removeComposerDraftTerminalContext, setPrompt, threadId], ); - const serverThread = threads.find((t) => t.id === threadId); - const fallbackDraftProject = projects.find((project) => project.id === draftThread?.projectId); + const fallbackDraftProject = useStore(selectProjectById(draftThread?.projectId)); + const threadPlanCatalog = useStore( + useShallow((store) => store.threads.map(toThreadPlanCatalogEntry)), + ); const localDraftError = serverThread ? null : (localDraftErrorsByThreadId[threadId] ?? null); const localDraftThread = useMemo( () => @@ -500,7 +526,7 @@ export default function ChatView({ threadId }: ChatViewProps) { [activeThread?.activities], ); const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null); - const activeProject = projects.find((p) => p.id === activeThread?.projectId); + const activeProject = useStore(selectProjectById(activeThread?.projectId)); const openPullRequestDialog = useCallback( (reference?: string) => { @@ -735,12 +761,12 @@ export default function ChatView({ threadId }: ChatViewProps) { const sidebarProposedPlan = useMemo( () => findSidebarProposedPlan({ - threads, + threads: threadPlanCatalog, latestTurn: activeLatestTurn, latestTurnSettled, threadId: activeThread?.id ?? null, }), - [activeLatestTurn, activeThread?.id, latestTurnSettled, threads], + [activeLatestTurn, activeThread?.id, latestTurnSettled, threadPlanCatalog], ); const activePlan = useMemo( () => deriveActivePlanState(threadActivities, activeLatestTurn?.turnId ?? undefined), @@ -1230,7 +1256,7 @@ export default function ChatView({ threadId }: ChatViewProps) { const setThreadError = useCallback( (targetThreadId: ThreadId | null, error: string | null) => { if (!targetThreadId) return; - if (threads.some((thread) => thread.id === targetThreadId)) { + if (useStore.getState().threads.some((thread) => thread.id === targetThreadId)) { setStoreThreadError(targetThreadId, error); return; } @@ -1244,7 +1270,7 @@ export default function ChatView({ threadId }: ChatViewProps) { }; }); }, - [setStoreThreadError, threads], + [setStoreThreadError], ); const focusComposer = useCallback(() => { @@ -3132,9 +3158,7 @@ export default function ChatView({ threadId }: ChatViewProps) { createdAt, }); }) - .then(() => api.orchestration.getSnapshot()) - .then((snapshot) => { - syncServerReadModel(snapshot); + .then(() => { // Signal that the plan sidebar should open on the new thread. planSidebarOpenOnNextThreadRef.current = true; return navigate({ @@ -3150,12 +3174,6 @@ export default function ChatView({ threadId }: ChatViewProps) { threadId: nextThreadId, }) .catch(() => undefined); - await api.orchestration - .getSnapshot() - .then((snapshot) => { - syncServerReadModel(snapshot); - }) - .catch(() => undefined); toastManager.add({ type: "error", title: "Could not start implementation thread", @@ -3179,7 +3197,6 @@ export default function ChatView({ threadId }: ChatViewProps) { selectedModelSelection, selectedProvider, selectedProviderModels, - syncServerReadModel, selectedModel, ]); diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 1e0871e0d2..b9ee51e20a 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -15,7 +15,10 @@ type SidebarProject = { createdAt?: string | undefined; updatedAt?: string | undefined; }; -type SidebarThreadSortInput = Pick; +type SidebarThreadSortInput = Pick & { + latestUserMessageAt?: string | null; + messages?: Pick[]; +}; export type ThreadTraversalDirection = "previous" | "next"; @@ -237,14 +240,14 @@ export function resolveProjectStatusIndicator( return highestPriorityStatus; } -export function getVisibleThreadsForProject(input: { - threads: readonly Thread[]; - activeThreadId: Thread["id"] | undefined; +export function getVisibleThreadsForProject>(input: { + threads: readonly T[]; + activeThreadId: T["id"] | undefined; isThreadListExpanded: boolean; previewLimit: number; }): { hasHiddenThreads: boolean; - visibleThreads: Thread[]; + visibleThreads: T[]; } { const { activeThreadId, isThreadListExpanded, previewLimit, threads } = input; const hasHiddenThreads = threads.length > previewLimit; @@ -287,9 +290,13 @@ function toSortableTimestamp(iso: string | undefined): number | null { } function getLatestUserMessageTimestamp(thread: SidebarThreadSortInput): number { + if (thread.latestUserMessageAt) { + return toSortableTimestamp(thread.latestUserMessageAt) ?? Number.NEGATIVE_INFINITY; + } + let latestUserMessageTimestamp: number | null = null; - for (const message of thread.messages) { + for (const message of thread.messages ?? []) { if (message.role !== "user") continue; const messageTimestamp = toSortableTimestamp(message.createdAt); if (messageTimestamp === null) continue; @@ -317,7 +324,7 @@ function getThreadSortTimestamp( } export function sortThreadsForSidebar< - T extends Pick, + T extends Pick & SidebarThreadSortInput, >(threads: readonly T[], sortOrder: SidebarThreadSortOrder): T[] { return threads.toSorted((left, right) => { const rightTimestamp = getThreadSortTimestamp(right, sortOrder); @@ -330,7 +337,7 @@ export function sortThreadsForSidebar< } export function getFallbackThreadIdAfterDelete< - T extends Pick, + T extends Pick & SidebarThreadSortInput, >(input: { threads: readonly T[]; deletedThreadId: T["id"]; @@ -374,7 +381,10 @@ export function getProjectSortTimestamp( return toSortableTimestamp(project.updatedAt ?? project.createdAt) ?? Number.NEGATIVE_INFINITY; } -export function sortProjectsForSidebar( +export function sortProjectsForSidebar< + TProject extends SidebarProject, + TThread extends Pick & SidebarThreadSortInput, +>( projects: readonly TProject[], threads: readonly TThread[], sortOrder: SidebarProjectSortOrder, diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 100d0e3f47..4340460a6d 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -21,6 +21,7 @@ import { type MouseEvent, type PointerEvent, } from "react"; +import { useShallow } from "zustand/react/shallow"; import { DndContext, type DragCancelEvent, @@ -120,6 +121,7 @@ import { import { SidebarUpdatePill } from "./sidebar/SidebarUpdatePill"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { useSettings, useUpdateSettings } from "~/hooks/useSettings"; +import type { Thread } from "../types"; const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; const THREAD_PREVIEW_LIMIT = 6; @@ -136,6 +138,70 @@ const SIDEBAR_LIST_ANIMATION_OPTIONS = { duration: 180, easing: "ease-out", } as const; + +type SidebarThreadSnapshot = Pick< + Thread, + | "activities" + | "archivedAt" + | "branch" + | "createdAt" + | "id" + | "interactionMode" + | "lastVisitedAt" + | "latestTurn" + | "projectId" + | "proposedPlans" + | "session" + | "title" + | "updatedAt" + | "worktreePath" +> & { + latestUserMessageAt: string | null; +}; + +const sidebarThreadSnapshotCache = new WeakMap(); + +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 > latestUserMessageAt) { + latestUserMessageAt = message.createdAt; + } + } + + return latestUserMessageAt; +} + +function toSidebarThreadSnapshot(thread: Thread): SidebarThreadSnapshot { + const cached = sidebarThreadSnapshotCache.get(thread); + if (cached) { + return cached; + } + + const snapshot: SidebarThreadSnapshot = { + id: thread.id, + projectId: thread.projectId, + title: thread.title, + interactionMode: thread.interactionMode, + session: thread.session, + createdAt: thread.createdAt, + updatedAt: thread.updatedAt, + archivedAt: thread.archivedAt, + latestTurn: thread.latestTurn, + lastVisitedAt: thread.lastVisitedAt, + branch: thread.branch, + worktreePath: thread.worktreePath, + activities: thread.activities, + proposedPlans: thread.proposedPlans, + latestUserMessageAt: getLatestUserMessageAt(thread), + }; + sidebarThreadSnapshotCache.set(thread, snapshot); + return snapshot; +} interface TerminalStatusIndicator { label: "Terminal process running"; colorClass: string; @@ -320,8 +386,8 @@ function SortableProjectItem({ } export default function Sidebar() { - const projects = useStore((store) => store.projects); - const threads = useStore((store) => store.threads); + const projects = useStore(useShallow((store) => store.projects)); + const threads = useStore(useShallow((store) => store.threads.map(toSidebarThreadSnapshot))); const markThreadUnread = useStore((store) => store.markThreadUnread); const toggleProject = useStore((store) => store.toggleProject); const reorderProjects = useStore((store) => store.reorderProjects); diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index e99ec50226..5b2dfbdaf1 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -135,6 +135,7 @@ function errorDetails(error: unknown): string { } function EventRouter() { + const applyOrchestrationEvents = useStore((store) => store.applyOrchestrationEvents); const syncServerReadModel = useStore((store) => store.syncServerReadModel); const setProjectExpanded = useStore((store) => store.setProjectExpanded); const removeOrphanedTerminalStates = useTerminalStateStore( @@ -146,62 +147,45 @@ function EventRouter() { const pathnameRef = useRef(pathname); const handledBootstrapThreadIdRef = useRef(null); - pathnameRef.current = pathname; + useEffect(() => { + pathnameRef.current = pathname; + }, [pathname]); useEffect(() => { const api = readNativeApi(); if (!api) return; let disposed = false; let latestSequence = 0; - let syncing = false; - let pending = false; + let highestObservedSequence = 0; + let bootstrapped = false; + let snapshotSyncInFlight = false; + let replayInFlight = false; + let pendingReplay = false; let needsProviderInvalidation = false; - const flushSnapshotSync = async (): Promise => { - const snapshot = await api.orchestration.getSnapshot(); - if (disposed) return; - latestSequence = Math.max(latestSequence, snapshot.snapshotSequence); - syncServerReadModel(snapshot); - clearPromotedDraftThreads(new Set(snapshot.threads.map((t) => t.id))); + const syncThreadDerivedState = () => { + const threads = useStore.getState().threads; + clearPromotedDraftThreads(new Set(threads.map((thread) => thread.id))); const draftThreadIds = Object.keys( useComposerDraftStore.getState().draftThreadsByThreadId, ) as ThreadId[]; const activeThreadIds = collectActiveTerminalThreadIds({ - snapshotThreads: snapshot.threads, + snapshotThreads: threads.map((thread) => ({ id: thread.id, deletedAt: null })), draftThreadIds, }); removeOrphanedTerminalStates(activeThreadIds); - if (pending) { - pending = false; - await flushSnapshotSync(); - } - }; - - const syncSnapshot = async () => { - if (syncing) { - pending = true; - return; - } - syncing = true; - pending = false; - try { - await flushSnapshotSync(); - } catch { - // Keep prior state and wait for next domain event to trigger a resync. - } - syncing = false; }; - const domainEventFlushThrottler = new Throttler( + const queryInvalidationThrottler = new Throttler( () => { - if (needsProviderInvalidation) { - needsProviderInvalidation = false; - void queryClient.invalidateQueries({ queryKey: providerQueryKeys.all }); - // Invalidate workspace entry queries so the @-mention file picker - // reflects files created, deleted, or restored during this turn. - void queryClient.invalidateQueries({ queryKey: projectQueryKeys.all }); + if (!needsProviderInvalidation) { + return; } - void syncSnapshot(); + needsProviderInvalidation = false; + void queryClient.invalidateQueries({ queryKey: providerQueryKeys.all }); + // Invalidate workspace entry queries so the @-mention file picker + // reflects files created, deleted, or restored during this turn. + void queryClient.invalidateQueries({ queryKey: projectQueryKeys.all }); }, { wait: 100, @@ -210,15 +194,108 @@ function EventRouter() { }, ); + const applyEventBatch = (events: Parameters[0]) => { + const nextEvents = events + .filter((event) => event.sequence > latestSequence) + .toSorted((left, right) => left.sequence - right.sequence); + if (nextEvents.length === 0) { + return; + } + + latestSequence = nextEvents.at(-1)?.sequence ?? latestSequence; + highestObservedSequence = Math.max(highestObservedSequence, latestSequence); + + if ( + nextEvents.some( + (event) => + event.type === "thread.turn-diff-completed" || event.type === "thread.reverted", + ) + ) { + needsProviderInvalidation = true; + void queryInvalidationThrottler.maybeExecute(); + } + + applyOrchestrationEvents(nextEvents); + syncThreadDerivedState(); + }; + + const replayFromLatest = async (): Promise => { + if (!bootstrapped || snapshotSyncInFlight) { + pendingReplay = true; + return; + } + if (replayInFlight) { + pendingReplay = true; + return; + } + + replayInFlight = true; + pendingReplay = false; + try { + const events = await api.orchestration.replayEvents(latestSequence); + if (!disposed) { + applyEventBatch(events); + } + } catch { + bootstrapped = false; + void bootstrapSnapshot(); + } + + replayInFlight = false; + if ( + !disposed && + bootstrapped && + (pendingReplay || highestObservedSequence > latestSequence) + ) { + void replayFromLatest(); + } + }; + + const bootstrapSnapshot = async (): Promise => { + if (snapshotSyncInFlight) { + pendingReplay = true; + return; + } + + snapshotSyncInFlight = true; + try { + const snapshot = await api.orchestration.getSnapshot(); + if (!disposed) { + latestSequence = Math.max(latestSequence, snapshot.snapshotSequence); + highestObservedSequence = Math.max(highestObservedSequence, latestSequence); + syncServerReadModel(snapshot); + bootstrapped = true; + syncThreadDerivedState(); + } + } catch { + // Keep prior state and wait for welcome or a later replay attempt. + } + + snapshotSyncInFlight = false; + if ( + !disposed && + bootstrapped && + (pendingReplay || highestObservedSequence > latestSequence) + ) { + void replayFromLatest(); + } + }; + const unsubDomainEvent = api.orchestration.onDomainEvent((event) => { + highestObservedSequence = Math.max(highestObservedSequence, event.sequence); if (event.sequence <= latestSequence) { return; } - latestSequence = event.sequence; - if (event.type === "thread.turn-diff-completed" || event.type === "thread.reverted") { - needsProviderInvalidation = true; + if (!bootstrapped || snapshotSyncInFlight || replayInFlight) { + pendingReplay = true; + return; + } + if (event.sequence !== latestSequence + 1) { + pendingReplay = true; + void replayFromLatest(); + return; } - domainEventFlushThrottler.maybeExecute(); + applyEventBatch([event]); }); const unsubTerminalEvent = api.terminal.onEvent((event) => { const hasRunningSubprocess = terminalRunningSubprocessFromEvent(event); @@ -237,7 +314,7 @@ function EventRouter() { // Migrate old localStorage settings to server on first connect migrateLocalSettingsToServer(); void (async () => { - await syncSnapshot(); + await bootstrapSnapshot(); if (disposed) { return; } @@ -319,7 +396,7 @@ function EventRouter() { return () => { disposed = true; needsProviderInvalidation = false; - domainEventFlushThrottler.cancel(); + queryInvalidationThrottler.cancel(); unsubDomainEvent(); unsubTerminalEvent(); unsubWelcome(); @@ -327,6 +404,7 @@ function EventRouter() { unsubProvidersUpdated(); }; }, [ + applyOrchestrationEvents, navigate, queryClient, removeOrphanedTerminalStates, diff --git a/apps/web/src/store.test.ts b/apps/web/src/store.test.ts index db62bad523..255c337a23 100644 --- a/apps/web/src/store.test.ts +++ b/apps/web/src/store.test.ts @@ -1,13 +1,24 @@ import { + CheckpointRef, DEFAULT_MODEL_BY_PROVIDER, + EventId, + MessageId, ProjectId, ThreadId, TurnId, + type OrchestrationEvent, type OrchestrationReadModel, } from "@t3tools/contracts"; import { describe, expect, it } from "vitest"; -import { markThreadUnread, reorderProjects, syncServerReadModel, type AppState } from "./store"; +import { + applyOrchestrationEvent, + applyOrchestrationEvents, + markThreadUnread, + reorderProjects, + syncServerReadModel, + type AppState, +} from "./store"; import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type Thread } from "./types"; function makeThread(overrides: Partial = {}): Thread { @@ -57,6 +68,33 @@ function makeState(thread: Thread): AppState { }; } +function makeEvent( + type: T, + payload: Extract["payload"], + overrides: Partial> = {}, +): Extract { + const sequence = overrides.sequence ?? 1; + return { + sequence, + eventId: EventId.makeUnsafe(`event-${sequence}`), + aggregateKind: "thread", + aggregateId: + "threadId" in payload + ? payload.threadId + : "projectId" in payload + ? payload.projectId + : ProjectId.makeUnsafe("project-1"), + occurredAt: "2026-02-27T00:00:00.000Z", + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + type, + payload, + ...overrides, + } as Extract; +} + function makeReadModelThread(overrides: Partial) { return { id: ThreadId.makeUnsafe("thread-1"), @@ -347,3 +385,210 @@ describe("store read model sync", () => { expect(next.projects.map((project) => project.id)).toEqual([project2, project1, project3]); }); }); + +describe("incremental orchestration updates", () => { + it("updates only the affected thread for message events", () => { + const thread1 = makeThread({ + id: ThreadId.makeUnsafe("thread-1"), + messages: [ + { + id: MessageId.makeUnsafe("message-1"), + role: "assistant", + text: "hello", + turnId: TurnId.makeUnsafe("turn-1"), + createdAt: "2026-02-27T00:00:00.000Z", + completedAt: "2026-02-27T00:00:00.000Z", + streaming: false, + }, + ], + }); + const thread2 = makeThread({ id: ThreadId.makeUnsafe("thread-2") }); + const state: AppState = { + ...makeState(thread1), + threads: [thread1, thread2], + }; + + const next = applyOrchestrationEvent( + state, + makeEvent("thread.message-sent", { + threadId: thread1.id, + messageId: MessageId.makeUnsafe("message-1"), + role: "assistant", + text: " world", + turnId: TurnId.makeUnsafe("turn-1"), + streaming: true, + createdAt: "2026-02-27T00:00:01.000Z", + updatedAt: "2026-02-27T00:00:01.000Z", + }), + ); + + expect(next.threads[0]?.messages[0]?.text).toBe("hello world"); + expect(next.threads[0]?.latestTurn?.state).toBe("running"); + expect(next.threads[1]).toBe(thread2); + }); + + it("applies replay batches in sequence and updates session state", () => { + const thread = makeThread({ + latestTurn: { + turnId: TurnId.makeUnsafe("turn-1"), + state: "running", + requestedAt: "2026-02-27T00:00:00.000Z", + startedAt: "2026-02-27T00:00:00.000Z", + completedAt: null, + assistantMessageId: null, + }, + }); + const state = makeState(thread); + + const next = applyOrchestrationEvents(state, [ + makeEvent( + "thread.session-set", + { + threadId: thread.id, + session: { + threadId: thread.id, + status: "running", + providerName: "codex", + runtimeMode: "full-access", + activeTurnId: TurnId.makeUnsafe("turn-1"), + lastError: null, + updatedAt: "2026-02-27T00:00:02.000Z", + }, + }, + { sequence: 2 }, + ), + makeEvent( + "thread.message-sent", + { + threadId: thread.id, + messageId: MessageId.makeUnsafe("assistant-1"), + role: "assistant", + text: "done", + turnId: TurnId.makeUnsafe("turn-1"), + streaming: false, + createdAt: "2026-02-27T00:00:03.000Z", + updatedAt: "2026-02-27T00:00:03.000Z", + }, + { sequence: 3 }, + ), + ]); + + expect(next.threads[0]?.session?.status).toBe("running"); + expect(next.threads[0]?.latestTurn?.state).toBe("completed"); + expect(next.threads[0]?.messages).toHaveLength(1); + }); + + it("reverts messages, plans, activities, and checkpoints by retained turns", () => { + const state = makeState( + makeThread({ + messages: [ + { + id: MessageId.makeUnsafe("user-1"), + role: "user", + text: "first", + turnId: TurnId.makeUnsafe("turn-1"), + createdAt: "2026-02-27T00:00:00.000Z", + completedAt: "2026-02-27T00:00:00.000Z", + streaming: false, + }, + { + id: MessageId.makeUnsafe("assistant-1"), + role: "assistant", + text: "first reply", + turnId: TurnId.makeUnsafe("turn-1"), + createdAt: "2026-02-27T00:00:01.000Z", + completedAt: "2026-02-27T00:00:01.000Z", + streaming: false, + }, + { + id: MessageId.makeUnsafe("user-2"), + role: "user", + text: "second", + turnId: TurnId.makeUnsafe("turn-2"), + createdAt: "2026-02-27T00:00:02.000Z", + completedAt: "2026-02-27T00:00:02.000Z", + streaming: false, + }, + ], + proposedPlans: [ + { + id: "plan-1", + turnId: TurnId.makeUnsafe("turn-1"), + planMarkdown: "plan 1", + implementedAt: null, + implementationThreadId: null, + createdAt: "2026-02-27T00:00:00.000Z", + updatedAt: "2026-02-27T00:00:00.000Z", + }, + { + id: "plan-2", + turnId: TurnId.makeUnsafe("turn-2"), + planMarkdown: "plan 2", + implementedAt: null, + implementationThreadId: null, + createdAt: "2026-02-27T00:00:02.000Z", + updatedAt: "2026-02-27T00:00:02.000Z", + }, + ], + activities: [ + { + id: EventId.makeUnsafe("activity-1"), + tone: "info", + kind: "step", + summary: "one", + payload: {}, + turnId: TurnId.makeUnsafe("turn-1"), + createdAt: "2026-02-27T00:00:00.000Z", + }, + { + id: EventId.makeUnsafe("activity-2"), + tone: "info", + kind: "step", + summary: "two", + payload: {}, + turnId: TurnId.makeUnsafe("turn-2"), + createdAt: "2026-02-27T00:00:02.000Z", + }, + ], + turnDiffSummaries: [ + { + turnId: TurnId.makeUnsafe("turn-1"), + completedAt: "2026-02-27T00:00:01.000Z", + status: "ready", + checkpointTurnCount: 1, + checkpointRef: CheckpointRef.makeUnsafe("ref-1"), + files: [], + }, + { + turnId: TurnId.makeUnsafe("turn-2"), + completedAt: "2026-02-27T00:00:03.000Z", + status: "ready", + checkpointTurnCount: 2, + checkpointRef: CheckpointRef.makeUnsafe("ref-2"), + files: [], + }, + ], + }), + ); + + const next = applyOrchestrationEvent( + state, + makeEvent("thread.reverted", { + threadId: ThreadId.makeUnsafe("thread-1"), + turnCount: 1, + }), + ); + + expect(next.threads[0]?.messages.map((message) => message.id)).toEqual([ + "user-1", + "assistant-1", + ]); + expect(next.threads[0]?.proposedPlans.map((plan) => plan.id)).toEqual(["plan-1"]); + expect(next.threads[0]?.activities.map((activity) => activity.id)).toEqual([ + EventId.makeUnsafe("activity-1"), + ]); + expect(next.threads[0]?.turnDiffSummaries.map((summary) => summary.turnId)).toEqual([ + TurnId.makeUnsafe("turn-1"), + ]); + }); +}); diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index a5beb5b1bf..37d0c5c501 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -1,8 +1,14 @@ import { Fragment, type ReactNode, createElement, useEffect } from "react"; import { + type OrchestrationEvent, + type OrchestrationMessage, + type OrchestrationProposedPlan, type ProviderKind, ThreadId, type OrchestrationReadModel, + type OrchestrationSession, + type OrchestrationCheckpointSummary, + type OrchestrationThread, type OrchestrationSessionStatus, } from "@t3tools/contracts"; import { resolveModelSlugForProvider } from "@t3tools/shared/model"; @@ -38,6 +44,10 @@ const initialState: AppState = { }; const persistedExpandedProjectCwds = new Set(); const persistedProjectOrderCwds: string[] = []; +const MAX_THREAD_MESSAGES = 2_000; +const MAX_THREAD_CHECKPOINTS = 500; +const MAX_THREAD_PROPOSED_PLANS = 200; +const MAX_THREAD_ACTIVITIES = 500; // ── Persist helpers ────────────────────────────────────────────────── @@ -111,6 +121,285 @@ function updateThread( return changed ? next : threads; } +function updateProject( + projects: Project[], + projectId: Project["id"], + updater: (project: Project) => Project, +): Project[] { + let changed = false; + const next = projects.map((project) => { + if (project.id !== projectId) { + return project; + } + const updated = updater(project); + if (updated !== project) { + changed = true; + } + return updated; + }); + return changed ? next : projects; +} + +function normalizeModelSelection( + selection: T, +): T { + return { + ...selection, + model: resolveModelSlugForProvider(selection.provider, selection.model), + }; +} + +function mapProjectScripts(scripts: ReadonlyArray): Project["scripts"] { + return scripts.map((script) => ({ ...script })); +} + +function mapSession(session: OrchestrationSession): Thread["session"] { + return { + provider: toLegacyProvider(session.providerName), + status: toLegacySessionStatus(session.status), + orchestrationStatus: session.status, + activeTurnId: session.activeTurnId ?? undefined, + createdAt: session.updatedAt, + updatedAt: session.updatedAt, + ...(session.lastError ? { lastError: session.lastError } : {}), + }; +} + +function mapMessage(message: OrchestrationMessage): ChatMessage { + const attachments = message.attachments?.map((attachment) => ({ + type: "image" as const, + id: attachment.id, + name: attachment.name, + mimeType: attachment.mimeType, + sizeBytes: attachment.sizeBytes, + previewUrl: toAttachmentPreviewUrl(attachmentPreviewRoutePath(attachment.id)), + })); + + return { + id: message.id, + role: message.role, + text: message.text, + turnId: message.turnId, + createdAt: message.createdAt, + streaming: message.streaming, + ...(message.streaming ? {} : { completedAt: message.updatedAt }), + ...(attachments && attachments.length > 0 ? { attachments } : {}), + }; +} + +function mapProposedPlan(proposedPlan: OrchestrationProposedPlan): Thread["proposedPlans"][number] { + return { + id: proposedPlan.id, + turnId: proposedPlan.turnId, + planMarkdown: proposedPlan.planMarkdown, + implementedAt: proposedPlan.implementedAt, + implementationThreadId: proposedPlan.implementationThreadId, + createdAt: proposedPlan.createdAt, + updatedAt: proposedPlan.updatedAt, + }; +} + +function mapTurnDiffSummary( + checkpoint: OrchestrationCheckpointSummary, +): Thread["turnDiffSummaries"][number] { + return { + turnId: checkpoint.turnId, + completedAt: checkpoint.completedAt, + status: checkpoint.status, + assistantMessageId: checkpoint.assistantMessageId ?? undefined, + checkpointTurnCount: checkpoint.checkpointTurnCount, + checkpointRef: checkpoint.checkpointRef, + files: checkpoint.files.map((file) => ({ ...file })), + }; +} + +function mapThread(thread: OrchestrationThread, existing?: Thread): Thread { + return { + id: thread.id, + codexThreadId: null, + projectId: thread.projectId, + title: thread.title, + modelSelection: normalizeModelSelection(thread.modelSelection), + runtimeMode: thread.runtimeMode, + interactionMode: thread.interactionMode, + session: thread.session ? mapSession(thread.session) : null, + messages: thread.messages.map(mapMessage), + proposedPlans: thread.proposedPlans.map(mapProposedPlan), + error: thread.session?.lastError ?? null, + createdAt: thread.createdAt, + archivedAt: thread.archivedAt, + updatedAt: thread.updatedAt, + latestTurn: thread.latestTurn, + lastVisitedAt: existing?.lastVisitedAt ?? thread.updatedAt, + branch: thread.branch, + worktreePath: thread.worktreePath, + turnDiffSummaries: thread.checkpoints.map(mapTurnDiffSummary), + activities: thread.activities.map((activity) => ({ ...activity })), + }; +} + +function mapProject( + project: OrchestrationReadModel["projects"][number], + existing?: Project, +): Project { + return { + id: project.id, + name: project.title, + cwd: project.workspaceRoot, + defaultModelSelection: + existing?.defaultModelSelection ?? + (project.defaultModelSelection + ? normalizeModelSelection(project.defaultModelSelection) + : null), + expanded: + existing?.expanded ?? + (persistedExpandedProjectCwds.size > 0 + ? persistedExpandedProjectCwds.has(project.workspaceRoot) + : true), + createdAt: project.createdAt, + updatedAt: project.updatedAt, + scripts: mapProjectScripts(project.scripts), + }; +} + +function checkpointStatusToLatestTurnState(status: "ready" | "missing" | "error") { + if (status === "error") { + return "error" as const; + } + if (status === "missing") { + return "interrupted" as const; + } + return "completed" as const; +} + +function compareActivities( + left: Thread["activities"][number], + right: Thread["activities"][number], +): number { + if (left.sequence !== undefined && right.sequence !== undefined) { + if (left.sequence !== right.sequence) { + return left.sequence - right.sequence; + } + } else if (left.sequence !== undefined) { + return 1; + } else if (right.sequence !== undefined) { + return -1; + } + + return left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id); +} + +function buildLatestTurn(params: { + previous: Thread["latestTurn"]; + turnId: NonNullable["turnId"]; + state: NonNullable["state"]; + requestedAt: string; + startedAt: string | null; + completedAt: string | null; + assistantMessageId: NonNullable["assistantMessageId"]; +}): NonNullable { + return { + turnId: params.turnId, + state: params.state, + requestedAt: params.requestedAt, + startedAt: params.startedAt, + completedAt: params.completedAt, + assistantMessageId: params.assistantMessageId, + ...(params.previous?.turnId === params.turnId && params.previous.sourceProposedPlan + ? { sourceProposedPlan: params.previous.sourceProposedPlan } + : {}), + }; +} + +function retainThreadMessagesAfterRevert( + messages: ReadonlyArray, + retainedTurnIds: ReadonlySet, + turnCount: number, +): ChatMessage[] { + const retainedMessageIds = new Set(); + for (const message of messages) { + if (message.role === "system") { + retainedMessageIds.add(message.id); + continue; + } + if ( + message.turnId !== undefined && + message.turnId !== null && + retainedTurnIds.has(message.turnId) + ) { + retainedMessageIds.add(message.id); + } + } + + const retainedUserCount = messages.filter( + (message) => message.role === "user" && retainedMessageIds.has(message.id), + ).length; + const missingUserCount = Math.max(0, turnCount - retainedUserCount); + if (missingUserCount > 0) { + const fallbackUserMessages = messages + .filter( + (message) => + message.role === "user" && + !retainedMessageIds.has(message.id) && + (message.turnId === undefined || + message.turnId === null || + retainedTurnIds.has(message.turnId)), + ) + .toSorted( + (left, right) => + left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id), + ) + .slice(0, missingUserCount); + for (const message of fallbackUserMessages) { + retainedMessageIds.add(message.id); + } + } + + const retainedAssistantCount = messages.filter( + (message) => message.role === "assistant" && retainedMessageIds.has(message.id), + ).length; + const missingAssistantCount = Math.max(0, turnCount - retainedAssistantCount); + if (missingAssistantCount > 0) { + const fallbackAssistantMessages = messages + .filter( + (message) => + message.role === "assistant" && + !retainedMessageIds.has(message.id) && + (message.turnId === undefined || + message.turnId === null || + retainedTurnIds.has(message.turnId)), + ) + .toSorted( + (left, right) => + left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id), + ) + .slice(0, missingAssistantCount); + for (const message of fallbackAssistantMessages) { + retainedMessageIds.add(message.id); + } + } + + return messages.filter((message) => retainedMessageIds.has(message.id)); +} + +function retainThreadActivitiesAfterRevert( + activities: ReadonlyArray, + retainedTurnIds: ReadonlySet, +): Thread["activities"] { + return activities.filter( + (activity) => activity.turnId === null || retainedTurnIds.has(activity.turnId), + ); +} + +function retainThreadProposedPlansAfterRevert( + proposedPlans: ReadonlyArray, + retainedTurnIds: ReadonlySet, +): Thread["proposedPlans"] { + return proposedPlans.filter( + (proposedPlan) => proposedPlan.turnId === null || retainedTurnIds.has(proposedPlan.turnId), + ); +} + function mapProjectsFromReadModel( incoming: OrchestrationReadModel["projects"], previous: Project[], @@ -128,30 +417,7 @@ function mapProjectsFromReadModel( const mappedProjects = incoming.map((project) => { const existing = previousById.get(project.id) ?? previousByCwd.get(project.workspaceRoot); - return { - id: project.id, - name: project.title, - cwd: project.workspaceRoot, - defaultModelSelection: - existing?.defaultModelSelection ?? - (project.defaultModelSelection - ? { - ...project.defaultModelSelection, - model: resolveModelSlugForProvider( - project.defaultModelSelection.provider, - project.defaultModelSelection.model, - ), - } - : null), - expanded: - existing?.expanded ?? - (persistedExpandedProjectCwds.size > 0 - ? persistedExpandedProjectCwds.has(project.workspaceRoot) - : true), - createdAt: project.createdAt, - updatedAt: project.updatedAt, - scripts: project.scripts.map((script) => ({ ...script })), - } satisfies Project; + return mapProject(project, existing); }); return mappedProjects @@ -241,82 +507,7 @@ export function syncServerReadModel(state: AppState, readModel: OrchestrationRea const existingThreadById = new Map(state.threads.map((thread) => [thread.id, thread] as const)); const threads = readModel.threads .filter((thread) => thread.deletedAt === null) - .map((thread) => { - const existing = existingThreadById.get(thread.id); - return { - id: thread.id, - codexThreadId: null, - projectId: thread.projectId, - title: thread.title, - modelSelection: { - ...thread.modelSelection, - model: resolveModelSlugForProvider( - thread.modelSelection.provider, - thread.modelSelection.model, - ), - }, - runtimeMode: thread.runtimeMode, - interactionMode: thread.interactionMode, - session: thread.session - ? { - provider: toLegacyProvider(thread.session.providerName), - status: toLegacySessionStatus(thread.session.status), - orchestrationStatus: thread.session.status, - activeTurnId: thread.session.activeTurnId ?? undefined, - createdAt: thread.session.updatedAt, - updatedAt: thread.session.updatedAt, - ...(thread.session.lastError ? { lastError: thread.session.lastError } : {}), - } - : null, - messages: thread.messages.map((message) => { - const attachments = message.attachments?.map((attachment) => ({ - type: "image" as const, - id: attachment.id, - name: attachment.name, - mimeType: attachment.mimeType, - sizeBytes: attachment.sizeBytes, - previewUrl: toAttachmentPreviewUrl(attachmentPreviewRoutePath(attachment.id)), - })); - const normalizedMessage: ChatMessage = { - id: message.id, - role: message.role, - text: message.text, - createdAt: message.createdAt, - streaming: message.streaming, - ...(message.streaming ? {} : { completedAt: message.updatedAt }), - ...(attachments && attachments.length > 0 ? { attachments } : {}), - }; - return normalizedMessage; - }), - proposedPlans: thread.proposedPlans.map((proposedPlan) => ({ - id: proposedPlan.id, - turnId: proposedPlan.turnId, - planMarkdown: proposedPlan.planMarkdown, - implementedAt: proposedPlan.implementedAt, - implementationThreadId: proposedPlan.implementationThreadId, - createdAt: proposedPlan.createdAt, - updatedAt: proposedPlan.updatedAt, - })), - error: thread.session?.lastError ?? null, - createdAt: thread.createdAt, - archivedAt: thread.archivedAt, - updatedAt: thread.updatedAt, - latestTurn: thread.latestTurn, - lastVisitedAt: existing?.lastVisitedAt ?? thread.updatedAt, - branch: thread.branch, - worktreePath: thread.worktreePath, - turnDiffSummaries: thread.checkpoints.map((checkpoint) => ({ - turnId: checkpoint.turnId, - completedAt: checkpoint.completedAt, - status: checkpoint.status, - assistantMessageId: checkpoint.assistantMessageId ?? undefined, - checkpointTurnCount: checkpoint.checkpointTurnCount, - checkpointRef: checkpoint.checkpointRef, - files: checkpoint.files.map((file) => ({ ...file })), - })), - activities: thread.activities.map((activity) => ({ ...activity })), - }; - }); + .map((thread) => mapThread(thread, existingThreadById.get(thread.id))); return { ...state, projects, @@ -325,6 +516,487 @@ export function syncServerReadModel(state: AppState, readModel: OrchestrationRea }; } +export function applyOrchestrationEvent(state: AppState, event: OrchestrationEvent): AppState { + switch (event.type) { + case "project.created": { + const existingIndex = state.projects.findIndex( + (project) => + project.id === event.payload.projectId || project.cwd === event.payload.workspaceRoot, + ); + const existing = existingIndex >= 0 ? state.projects[existingIndex] : undefined; + const nextProject = mapProject( + { + id: event.payload.projectId, + title: event.payload.title, + workspaceRoot: event.payload.workspaceRoot, + defaultModelSelection: event.payload.defaultModelSelection, + scripts: event.payload.scripts, + createdAt: event.payload.createdAt, + updatedAt: event.payload.updatedAt, + deletedAt: null, + }, + existing, + ); + const projects = + existingIndex >= 0 + ? state.projects.map((project, index) => + index === existingIndex ? nextProject : project, + ) + : [...state.projects, nextProject]; + return { ...state, projects, threadsHydrated: true }; + } + + case "project.meta-updated": { + const projects = updateProject(state.projects, event.payload.projectId, (project) => ({ + ...project, + ...(event.payload.title !== undefined ? { name: event.payload.title } : {}), + ...(event.payload.workspaceRoot !== undefined ? { cwd: event.payload.workspaceRoot } : {}), + ...(event.payload.defaultModelSelection !== undefined + ? { + defaultModelSelection: event.payload.defaultModelSelection + ? normalizeModelSelection(event.payload.defaultModelSelection) + : null, + } + : {}), + ...(event.payload.scripts !== undefined + ? { scripts: mapProjectScripts(event.payload.scripts) } + : {}), + updatedAt: event.payload.updatedAt, + })); + return projects === state.projects ? state : { ...state, projects, threadsHydrated: true }; + } + + case "project.deleted": { + const projects = state.projects.filter((project) => project.id !== event.payload.projectId); + return projects === state.projects ? state : { ...state, projects, threadsHydrated: true }; + } + + case "thread.created": { + const existing = state.threads.find((thread) => thread.id === event.payload.threadId); + const nextThread = mapThread( + { + id: event.payload.threadId, + projectId: event.payload.projectId, + title: event.payload.title, + modelSelection: event.payload.modelSelection, + runtimeMode: event.payload.runtimeMode, + interactionMode: event.payload.interactionMode, + branch: event.payload.branch, + worktreePath: event.payload.worktreePath, + latestTurn: null, + createdAt: event.payload.createdAt, + updatedAt: event.payload.updatedAt, + archivedAt: null, + deletedAt: null, + messages: [], + proposedPlans: [], + activities: [], + checkpoints: [], + session: null, + }, + existing, + ); + const threads = existing + ? state.threads.map((thread) => (thread.id === nextThread.id ? nextThread : thread)) + : [...state.threads, nextThread]; + return { ...state, threads, threadsHydrated: true }; + } + + case "thread.deleted": { + const threads = state.threads.filter((thread) => thread.id !== event.payload.threadId); + return threads === state.threads ? state : { ...state, threads, threadsHydrated: true }; + } + + case "thread.archived": { + const threads = updateThread(state.threads, event.payload.threadId, (thread) => ({ + ...thread, + archivedAt: event.payload.archivedAt, + updatedAt: event.payload.updatedAt, + })); + return threads === state.threads ? state : { ...state, threads, threadsHydrated: true }; + } + + case "thread.unarchived": { + const threads = updateThread(state.threads, event.payload.threadId, (thread) => ({ + ...thread, + archivedAt: null, + updatedAt: event.payload.updatedAt, + })); + return threads === state.threads ? state : { ...state, threads, threadsHydrated: true }; + } + + case "thread.meta-updated": { + const threads = updateThread(state.threads, event.payload.threadId, (thread) => ({ + ...thread, + ...(event.payload.title !== undefined ? { title: event.payload.title } : {}), + ...(event.payload.modelSelection !== undefined + ? { modelSelection: normalizeModelSelection(event.payload.modelSelection) } + : {}), + ...(event.payload.branch !== undefined ? { branch: event.payload.branch } : {}), + ...(event.payload.worktreePath !== undefined + ? { worktreePath: event.payload.worktreePath } + : {}), + updatedAt: event.payload.updatedAt, + })); + return threads === state.threads ? state : { ...state, threads, threadsHydrated: true }; + } + + case "thread.runtime-mode-set": { + const threads = updateThread(state.threads, event.payload.threadId, (thread) => ({ + ...thread, + runtimeMode: event.payload.runtimeMode, + updatedAt: event.payload.updatedAt, + })); + return threads === state.threads ? state : { ...state, threads, threadsHydrated: true }; + } + + case "thread.interaction-mode-set": { + const threads = updateThread(state.threads, event.payload.threadId, (thread) => ({ + ...thread, + interactionMode: event.payload.interactionMode, + updatedAt: event.payload.updatedAt, + })); + return threads === state.threads ? state : { ...state, threads, threadsHydrated: true }; + } + + case "thread.turn-start-requested": { + const threads = updateThread(state.threads, event.payload.threadId, (thread) => ({ + ...thread, + ...(event.payload.modelSelection !== undefined + ? { modelSelection: normalizeModelSelection(event.payload.modelSelection) } + : {}), + runtimeMode: event.payload.runtimeMode, + interactionMode: event.payload.interactionMode, + updatedAt: event.occurredAt, + })); + return threads === state.threads ? state : { ...state, threads, threadsHydrated: true }; + } + + case "thread.turn-interrupt-requested": { + if (event.payload.turnId === undefined) { + return state; + } + const threads = updateThread(state.threads, event.payload.threadId, (thread) => { + const latestTurn = thread.latestTurn; + if (latestTurn === null || latestTurn.turnId !== event.payload.turnId) { + return thread; + } + return { + ...thread, + latestTurn: buildLatestTurn({ + previous: latestTurn, + turnId: event.payload.turnId, + state: "interrupted", + requestedAt: latestTurn.requestedAt, + startedAt: latestTurn.startedAt ?? event.payload.createdAt, + completedAt: latestTurn.completedAt ?? event.payload.createdAt, + assistantMessageId: latestTurn.assistantMessageId, + }), + updatedAt: event.occurredAt, + }; + }); + return threads === state.threads ? state : { ...state, threads, threadsHydrated: true }; + } + + case "thread.message-sent": { + const threads = updateThread(state.threads, event.payload.threadId, (thread) => { + const message = mapMessage({ + id: event.payload.messageId, + role: event.payload.role, + text: event.payload.text, + ...(event.payload.attachments !== undefined + ? { attachments: event.payload.attachments } + : {}), + turnId: event.payload.turnId, + streaming: event.payload.streaming, + createdAt: event.payload.createdAt, + updatedAt: event.payload.updatedAt, + }); + const existingMessage = thread.messages.find((entry) => entry.id === message.id); + const messages = existingMessage + ? thread.messages.map((entry) => + entry.id !== message.id + ? entry + : { + ...entry, + text: message.streaming + ? `${entry.text}${message.text}` + : message.text.length > 0 + ? message.text + : entry.text, + streaming: message.streaming, + ...(message.turnId !== undefined ? { turnId: message.turnId } : {}), + ...(message.streaming + ? entry.completedAt !== undefined + ? { completedAt: entry.completedAt } + : {} + : message.completedAt !== undefined + ? { completedAt: message.completedAt } + : {}), + ...(message.attachments !== undefined + ? { attachments: message.attachments } + : {}), + }, + ) + : [...thread.messages, message]; + const cappedMessages = messages.slice(-MAX_THREAD_MESSAGES); + const latestTurn: Thread["latestTurn"] = + event.payload.role === "assistant" && event.payload.turnId !== null + ? buildLatestTurn({ + previous: thread.latestTurn, + turnId: event.payload.turnId, + state: event.payload.streaming + ? "running" + : thread.latestTurn?.state === "interrupted" + ? "interrupted" + : thread.latestTurn?.state === "error" + ? "error" + : "completed", + requestedAt: + thread.latestTurn?.turnId === event.payload.turnId + ? thread.latestTurn.requestedAt + : event.payload.createdAt, + startedAt: + thread.latestTurn?.turnId === event.payload.turnId + ? (thread.latestTurn.startedAt ?? event.payload.createdAt) + : event.payload.createdAt, + completedAt: event.payload.streaming + ? thread.latestTurn?.turnId === event.payload.turnId + ? (thread.latestTurn.completedAt ?? null) + : null + : event.payload.updatedAt, + assistantMessageId: event.payload.messageId, + }) + : thread.latestTurn; + return { + ...thread, + messages: cappedMessages, + latestTurn, + updatedAt: event.occurredAt, + }; + }); + return threads === state.threads ? state : { ...state, threads, threadsHydrated: true }; + } + + case "thread.session-set": { + const threads = updateThread(state.threads, event.payload.threadId, (thread) => ({ + ...thread, + session: mapSession(event.payload.session), + error: event.payload.session.lastError ?? null, + latestTurn: + event.payload.session.status === "running" && event.payload.session.activeTurnId !== null + ? buildLatestTurn({ + previous: thread.latestTurn, + turnId: event.payload.session.activeTurnId, + state: "running", + requestedAt: + thread.latestTurn?.turnId === event.payload.session.activeTurnId + ? thread.latestTurn.requestedAt + : event.payload.session.updatedAt, + startedAt: + thread.latestTurn?.turnId === event.payload.session.activeTurnId + ? (thread.latestTurn.startedAt ?? event.payload.session.updatedAt) + : event.payload.session.updatedAt, + completedAt: null, + assistantMessageId: + thread.latestTurn?.turnId === event.payload.session.activeTurnId + ? thread.latestTurn.assistantMessageId + : null, + }) + : thread.latestTurn, + updatedAt: event.occurredAt, + })); + return threads === state.threads ? state : { ...state, threads, threadsHydrated: true }; + } + + case "thread.session-stop-requested": { + const threads = updateThread(state.threads, event.payload.threadId, (thread) => + thread.session === null + ? thread + : { + ...thread, + session: { + ...thread.session, + status: "closed", + orchestrationStatus: "stopped", + activeTurnId: undefined, + updatedAt: event.payload.createdAt, + }, + updatedAt: event.occurredAt, + }, + ); + return threads === state.threads ? state : { ...state, threads, threadsHydrated: true }; + } + + case "thread.proposed-plan-upserted": { + const threads = updateThread(state.threads, event.payload.threadId, (thread) => { + const proposedPlan = mapProposedPlan(event.payload.proposedPlan); + const proposedPlans = [ + ...thread.proposedPlans.filter((entry) => entry.id !== proposedPlan.id), + proposedPlan, + ] + .toSorted( + (left, right) => + left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id), + ) + .slice(-MAX_THREAD_PROPOSED_PLANS); + return { + ...thread, + proposedPlans, + updatedAt: event.occurredAt, + }; + }); + return threads === state.threads ? state : { ...state, threads, threadsHydrated: true }; + } + + case "thread.turn-diff-completed": { + const threads = updateThread(state.threads, event.payload.threadId, (thread) => { + const checkpoint = mapTurnDiffSummary({ + turnId: event.payload.turnId, + checkpointTurnCount: event.payload.checkpointTurnCount, + checkpointRef: event.payload.checkpointRef, + status: event.payload.status, + files: event.payload.files, + assistantMessageId: event.payload.assistantMessageId, + completedAt: event.payload.completedAt, + }); + const existing = thread.turnDiffSummaries.find( + (entry) => entry.turnId === checkpoint.turnId, + ); + if (existing && existing.status !== "missing" && checkpoint.status === "missing") { + return thread; + } + const turnDiffSummaries = [ + ...thread.turnDiffSummaries.filter((entry) => entry.turnId !== checkpoint.turnId), + checkpoint, + ] + .toSorted( + (left, right) => + (left.checkpointTurnCount ?? Number.MAX_SAFE_INTEGER) - + (right.checkpointTurnCount ?? Number.MAX_SAFE_INTEGER), + ) + .slice(-MAX_THREAD_CHECKPOINTS); + return { + ...thread, + turnDiffSummaries, + latestTurn: buildLatestTurn({ + previous: thread.latestTurn, + turnId: event.payload.turnId, + state: checkpointStatusToLatestTurnState(event.payload.status), + requestedAt: + thread.latestTurn?.turnId === event.payload.turnId + ? thread.latestTurn.requestedAt + : event.payload.completedAt, + startedAt: + thread.latestTurn?.turnId === event.payload.turnId + ? (thread.latestTurn.startedAt ?? event.payload.completedAt) + : event.payload.completedAt, + completedAt: event.payload.completedAt, + assistantMessageId: event.payload.assistantMessageId, + }), + updatedAt: event.occurredAt, + }; + }); + return threads === state.threads ? state : { ...state, threads, threadsHydrated: true }; + } + + case "thread.reverted": { + const threads = updateThread(state.threads, event.payload.threadId, (thread) => { + const turnDiffSummaries = thread.turnDiffSummaries + .filter( + (entry) => + entry.checkpointTurnCount !== undefined && + entry.checkpointTurnCount <= event.payload.turnCount, + ) + .toSorted( + (left, right) => + (left.checkpointTurnCount ?? Number.MAX_SAFE_INTEGER) - + (right.checkpointTurnCount ?? Number.MAX_SAFE_INTEGER), + ) + .slice(-MAX_THREAD_CHECKPOINTS); + const retainedTurnIds = new Set(turnDiffSummaries.map((entry) => entry.turnId)); + const messages = retainThreadMessagesAfterRevert( + thread.messages, + retainedTurnIds, + event.payload.turnCount, + ).slice(-MAX_THREAD_MESSAGES); + const proposedPlans = retainThreadProposedPlansAfterRevert( + thread.proposedPlans, + retainedTurnIds, + ).slice(-MAX_THREAD_PROPOSED_PLANS); + const activities = retainThreadActivitiesAfterRevert(thread.activities, retainedTurnIds); + const latestCheckpoint = turnDiffSummaries.at(-1) ?? null; + + return { + ...thread, + turnDiffSummaries, + messages, + proposedPlans, + activities, + latestTurn: + latestCheckpoint === null + ? null + : { + turnId: latestCheckpoint.turnId, + state: checkpointStatusToLatestTurnState( + (latestCheckpoint.status ?? "ready") as "ready" | "missing" | "error", + ), + requestedAt: latestCheckpoint.completedAt, + startedAt: latestCheckpoint.completedAt, + completedAt: latestCheckpoint.completedAt, + assistantMessageId: latestCheckpoint.assistantMessageId ?? null, + }, + updatedAt: event.occurredAt, + }; + }); + return threads === state.threads ? state : { ...state, threads, threadsHydrated: true }; + } + + case "thread.activity-appended": { + const threads = updateThread(state.threads, event.payload.threadId, (thread) => { + const activities = [ + ...thread.activities.filter((activity) => activity.id !== event.payload.activity.id), + { ...event.payload.activity }, + ] + .toSorted(compareActivities) + .slice(-MAX_THREAD_ACTIVITIES); + return { + ...thread, + activities, + updatedAt: event.occurredAt, + }; + }); + return threads === state.threads ? state : { ...state, threads, threadsHydrated: true }; + } + + case "thread.approval-response-requested": + case "thread.user-input-response-requested": + return state; + } + + return state; +} + +export function applyOrchestrationEvents( + state: AppState, + events: ReadonlyArray, +): AppState { + if (events.length === 0) { + return state; + } + return events.reduce((nextState, event) => applyOrchestrationEvent(nextState, event), state); +} + +export const selectProjectById = + (projectId: Project["id"] | null | undefined) => + (state: AppState): Project | undefined => + projectId ? state.projects.find((project) => project.id === projectId) : undefined; + +export const selectThreadById = + (threadId: ThreadId) => + (state: AppState): Thread | undefined => + state.threads.find((thread) => thread.id === threadId); + export function markThreadVisited( state: AppState, threadId: ThreadId, @@ -426,6 +1098,8 @@ export function setThreadBranch( interface AppStore extends AppState { syncServerReadModel: (readModel: OrchestrationReadModel) => void; + applyOrchestrationEvent: (event: OrchestrationEvent) => void; + applyOrchestrationEvents: (events: ReadonlyArray) => void; markThreadVisited: (threadId: ThreadId, visitedAt?: string) => void; markThreadUnread: (threadId: ThreadId) => void; toggleProject: (projectId: Project["id"]) => void; @@ -438,6 +1112,8 @@ interface AppStore extends AppState { export const useStore = create((set) => ({ ...readPersistedState(), syncServerReadModel: (readModel) => set((state) => syncServerReadModel(state, readModel)), + applyOrchestrationEvent: (event) => set((state) => applyOrchestrationEvent(state, event)), + applyOrchestrationEvents: (events) => set((state) => applyOrchestrationEvents(state, events)), markThreadVisited: (threadId, visitedAt) => set((state) => markThreadVisited(state, threadId, visitedAt)), markThreadUnread: (threadId) => set((state) => markThreadUnread(state, threadId)), diff --git a/apps/web/src/types.ts b/apps/web/src/types.ts index e6cb1efea6..eee7a35529 100644 --- a/apps/web/src/types.ts +++ b/apps/web/src/types.ts @@ -45,6 +45,7 @@ export interface ChatMessage { role: "user" | "assistant" | "system"; text: string; attachments?: ChatAttachment[]; + turnId?: TurnId | null; createdAt: string; completedAt?: string | undefined; streaming: boolean; From a7dc0c910811b2dbe688b0c054ebf5caa69afbf4 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 29 Mar 2026 17:24:56 -0700 Subject: [PATCH 02/22] Preserve state on no-op deletes and stale turn diffs - Keep store identity unchanged for missing project/thread deletes - Avoid regressing latestTurn when an older turn diff completes late --- apps/web/src/store.test.ts | 55 ++++++++++++++++++++++++++++++++++++++ apps/web/src/store.ts | 36 +++++++++++++------------ 2 files changed, 74 insertions(+), 17 deletions(-) diff --git a/apps/web/src/store.test.ts b/apps/web/src/store.test.ts index 255c337a23..46ee2a4323 100644 --- a/apps/web/src/store.test.ts +++ b/apps/web/src/store.test.ts @@ -387,6 +387,29 @@ describe("store read model sync", () => { }); describe("incremental orchestration updates", () => { + it("preserves state identity for no-op project and thread deletes", () => { + const thread = makeThread(); + const state = makeState(thread); + + const nextAfterProjectDelete = applyOrchestrationEvent( + state, + makeEvent("project.deleted", { + projectId: ProjectId.makeUnsafe("project-missing"), + deletedAt: "2026-02-27T00:00:01.000Z", + }), + ); + const nextAfterThreadDelete = applyOrchestrationEvent( + state, + makeEvent("thread.deleted", { + threadId: ThreadId.makeUnsafe("thread-missing"), + deletedAt: "2026-02-27T00:00:01.000Z", + }), + ); + + expect(nextAfterProjectDelete).toBe(state); + expect(nextAfterThreadDelete).toBe(state); + }); + it("updates only the affected thread for message events", () => { const thread1 = makeThread({ id: ThreadId.makeUnsafe("thread-1"), @@ -478,6 +501,38 @@ describe("incremental orchestration updates", () => { expect(next.threads[0]?.messages).toHaveLength(1); }); + it("does not regress latestTurn when an older turn diff completes late", () => { + const state = makeState( + makeThread({ + latestTurn: { + turnId: TurnId.makeUnsafe("turn-2"), + state: "running", + requestedAt: "2026-02-27T00:00:02.000Z", + startedAt: "2026-02-27T00:00:03.000Z", + completedAt: null, + assistantMessageId: null, + }, + }), + ); + + const next = applyOrchestrationEvent( + state, + makeEvent("thread.turn-diff-completed", { + threadId: ThreadId.makeUnsafe("thread-1"), + turnId: TurnId.makeUnsafe("turn-1"), + checkpointTurnCount: 1, + checkpointRef: CheckpointRef.makeUnsafe("checkpoint-1"), + status: "ready", + files: [], + assistantMessageId: MessageId.makeUnsafe("assistant-1"), + completedAt: "2026-02-27T00:00:04.000Z", + }), + ); + + expect(next.threads[0]?.turnDiffSummaries).toHaveLength(1); + expect(next.threads[0]?.latestTurn).toEqual(state.threads[0]?.latestTurn); + }); + it("reverts messages, plans, activities, and checkpoints by retained turns", () => { const state = makeState( makeThread({ diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index 37d0c5c501..d1adb8e883 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -568,7 +568,9 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve case "project.deleted": { const projects = state.projects.filter((project) => project.id !== event.payload.projectId); - return projects === state.projects ? state : { ...state, projects, threadsHydrated: true }; + return projects.length === state.projects.length + ? state + : { ...state, projects, threadsHydrated: true }; } case "thread.created": { @@ -604,7 +606,9 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve case "thread.deleted": { const threads = state.threads.filter((thread) => thread.id !== event.payload.threadId); - return threads === state.threads ? state : { ...state, threads, threadsHydrated: true }; + return threads.length === state.threads.length + ? state + : { ...state, threads, threadsHydrated: true }; } case "thread.archived": { @@ -876,24 +880,22 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve (right.checkpointTurnCount ?? Number.MAX_SAFE_INTEGER), ) .slice(-MAX_THREAD_CHECKPOINTS); + const latestTurn = + thread.latestTurn === null || thread.latestTurn.turnId === event.payload.turnId + ? buildLatestTurn({ + previous: thread.latestTurn, + turnId: event.payload.turnId, + state: checkpointStatusToLatestTurnState(event.payload.status), + requestedAt: thread.latestTurn?.requestedAt ?? event.payload.completedAt, + startedAt: thread.latestTurn?.startedAt ?? event.payload.completedAt, + completedAt: event.payload.completedAt, + assistantMessageId: event.payload.assistantMessageId, + }) + : thread.latestTurn; return { ...thread, turnDiffSummaries, - latestTurn: buildLatestTurn({ - previous: thread.latestTurn, - turnId: event.payload.turnId, - state: checkpointStatusToLatestTurnState(event.payload.status), - requestedAt: - thread.latestTurn?.turnId === event.payload.turnId - ? thread.latestTurn.requestedAt - : event.payload.completedAt, - startedAt: - thread.latestTurn?.turnId === event.payload.turnId - ? (thread.latestTurn.startedAt ?? event.payload.completedAt) - : event.payload.completedAt, - completedAt: event.payload.completedAt, - assistantMessageId: event.payload.assistantMessageId, - }), + latestTurn, updatedAt: event.occurredAt, }; }); From f6feb65bc385d64ad128f644f288b9216403532b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 29 Mar 2026 20:20:33 -0700 Subject: [PATCH 03/22] Refactor orchestration event batch cleanup - Derive batch effects for draft and terminal-state cleanup - Remove terminal state entries on thread delete - Add tests for lifecycle effect handling --- .../web/src/orchestrationEventEffects.test.ts | 106 ++++++++++++++++++ apps/web/src/orchestrationEventEffects.ts | 67 +++++++++++ apps/web/src/routes/__root.tsx | 24 ++-- apps/web/src/terminalStateStore.ts | 10 ++ 4 files changed, 198 insertions(+), 9 deletions(-) create mode 100644 apps/web/src/orchestrationEventEffects.test.ts create mode 100644 apps/web/src/orchestrationEventEffects.ts diff --git a/apps/web/src/orchestrationEventEffects.test.ts b/apps/web/src/orchestrationEventEffects.test.ts new file mode 100644 index 0000000000..5a7f387af8 --- /dev/null +++ b/apps/web/src/orchestrationEventEffects.test.ts @@ -0,0 +1,106 @@ +import { + CheckpointRef, + EventId, + MessageId, + ProjectId, + ThreadId, + TurnId, + type OrchestrationEvent, +} from "@t3tools/contracts"; +import { describe, expect, it } from "vitest"; + +import { deriveOrchestrationBatchEffects } from "./orchestrationEventEffects"; + +function makeEvent( + type: T, + payload: Extract["payload"], + overrides: Partial> = {}, +): Extract { + const sequence = overrides.sequence ?? 1; + return { + sequence, + eventId: EventId.makeUnsafe(`event-${sequence}`), + aggregateKind: "thread", + aggregateId: + "threadId" in payload + ? payload.threadId + : "projectId" in payload + ? payload.projectId + : ProjectId.makeUnsafe("project-1"), + occurredAt: "2026-02-27T00:00:00.000Z", + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + type, + payload, + ...overrides, + } as Extract; +} + +describe("deriveOrchestrationBatchEffects", () => { + it("targets draft promotion and terminal cleanup from thread lifecycle events", () => { + const createdThreadId = ThreadId.makeUnsafe("thread-created"); + const deletedThreadId = ThreadId.makeUnsafe("thread-deleted"); + + const effects = deriveOrchestrationBatchEffects([ + makeEvent("thread.created", { + threadId: createdThreadId, + projectId: ProjectId.makeUnsafe("project-1"), + title: "Created thread", + modelSelection: { provider: "codex", model: "gpt-5-codex" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt: "2026-02-27T00:00:00.000Z", + updatedAt: "2026-02-27T00:00:00.000Z", + }), + makeEvent("thread.deleted", { + threadId: deletedThreadId, + deletedAt: "2026-02-27T00:00:01.000Z", + }), + ]); + + expect(effects.clearDraftThreadIds).toEqual([createdThreadId, deletedThreadId]); + expect(effects.removeTerminalStateThreadIds).toEqual([deletedThreadId]); + expect(effects.needsProviderInvalidation).toBe(false); + }); + + it("keeps only the final lifecycle outcome for a thread within one batch", () => { + const threadId = ThreadId.makeUnsafe("thread-1"); + + const effects = deriveOrchestrationBatchEffects([ + makeEvent("thread.deleted", { + threadId, + deletedAt: "2026-02-27T00:00:01.000Z", + }), + makeEvent("thread.created", { + threadId, + projectId: ProjectId.makeUnsafe("project-1"), + title: "Recreated thread", + modelSelection: { provider: "codex", model: "gpt-5-codex" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt: "2026-02-27T00:00:02.000Z", + updatedAt: "2026-02-27T00:00:02.000Z", + }), + makeEvent("thread.turn-diff-completed", { + threadId, + turnId: TurnId.makeUnsafe("turn-1"), + checkpointTurnCount: 1, + checkpointRef: CheckpointRef.makeUnsafe("checkpoint-1"), + status: "ready", + files: [], + assistantMessageId: MessageId.makeUnsafe("assistant-1"), + completedAt: "2026-02-27T00:00:03.000Z", + }), + ]); + + expect(effects.clearDraftThreadIds).toEqual([threadId]); + expect(effects.removeTerminalStateThreadIds).toEqual([]); + expect(effects.needsProviderInvalidation).toBe(true); + }); +}); diff --git a/apps/web/src/orchestrationEventEffects.ts b/apps/web/src/orchestrationEventEffects.ts new file mode 100644 index 0000000000..7f955b786a --- /dev/null +++ b/apps/web/src/orchestrationEventEffects.ts @@ -0,0 +1,67 @@ +import type { OrchestrationEvent, ThreadId } from "@t3tools/contracts"; + +export interface OrchestrationBatchEffects { + clearDraftThreadIds: ThreadId[]; + removeTerminalStateThreadIds: ThreadId[]; + needsProviderInvalidation: boolean; +} + +export function deriveOrchestrationBatchEffects( + events: readonly OrchestrationEvent[], +): OrchestrationBatchEffects { + const threadLifecycleEffects = new Map< + ThreadId, + { + clearDraft: boolean; + removeTerminalState: boolean; + } + >(); + let needsProviderInvalidation = false; + + for (const event of events) { + switch (event.type) { + case "thread.turn-diff-completed": + case "thread.reverted": { + needsProviderInvalidation = true; + break; + } + + case "thread.created": { + threadLifecycleEffects.set(event.payload.threadId, { + clearDraft: true, + removeTerminalState: false, + }); + break; + } + + case "thread.deleted": { + threadLifecycleEffects.set(event.payload.threadId, { + clearDraft: true, + removeTerminalState: true, + }); + break; + } + + default: { + break; + } + } + } + + const clearDraftThreadIds: ThreadId[] = []; + const removeTerminalStateThreadIds: ThreadId[] = []; + for (const [threadId, effect] of threadLifecycleEffects) { + if (effect.clearDraft) { + clearDraftThreadIds.push(threadId); + } + if (effect.removeTerminalState) { + removeTerminalStateThreadIds.push(threadId); + } + } + + return { + clearDraftThreadIds, + removeTerminalStateThreadIds, + needsProviderInvalidation, + }; +} diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 5b2dfbdaf1..4a33021865 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -26,6 +26,7 @@ import { migrateLocalSettingsToServer } from "../hooks/useSettings"; import { providerQueryKeys } from "../lib/providerReactQuery"; import { projectQueryKeys } from "../lib/projectReactQuery"; import { collectActiveTerminalThreadIds } from "../lib/terminalStateCleanup"; +import { deriveOrchestrationBatchEffects } from "../orchestrationEventEffects"; export const Route = createRootRouteWithContext<{ queryClient: QueryClient; @@ -138,6 +139,7 @@ function EventRouter() { const applyOrchestrationEvents = useStore((store) => store.applyOrchestrationEvents); const syncServerReadModel = useStore((store) => store.syncServerReadModel); const setProjectExpanded = useStore((store) => store.setProjectExpanded); + const removeTerminalState = useTerminalStateStore((store) => store.removeTerminalState); const removeOrphanedTerminalStates = useTerminalStateStore( (store) => store.removeOrphanedTerminalStates, ); @@ -163,7 +165,7 @@ function EventRouter() { let pendingReplay = false; let needsProviderInvalidation = false; - const syncThreadDerivedState = () => { + const reconcileSnapshotDerivedState = () => { const threads = useStore.getState().threads; clearPromotedDraftThreads(new Set(threads.map((thread) => thread.id))); const draftThreadIds = Object.keys( @@ -205,18 +207,21 @@ function EventRouter() { latestSequence = nextEvents.at(-1)?.sequence ?? latestSequence; highestObservedSequence = Math.max(highestObservedSequence, latestSequence); - if ( - nextEvents.some( - (event) => - event.type === "thread.turn-diff-completed" || event.type === "thread.reverted", - ) - ) { + const batchEffects = deriveOrchestrationBatchEffects(nextEvents); + + if (batchEffects.needsProviderInvalidation) { needsProviderInvalidation = true; void queryInvalidationThrottler.maybeExecute(); } applyOrchestrationEvents(nextEvents); - syncThreadDerivedState(); + const draftStore = useComposerDraftStore.getState(); + for (const threadId of batchEffects.clearDraftThreadIds) { + draftStore.clearDraftThread(threadId); + } + for (const threadId of batchEffects.removeTerminalStateThreadIds) { + removeTerminalState(threadId); + } }; const replayFromLatest = async (): Promise => { @@ -265,7 +270,7 @@ function EventRouter() { highestObservedSequence = Math.max(highestObservedSequence, latestSequence); syncServerReadModel(snapshot); bootstrapped = true; - syncThreadDerivedState(); + reconcileSnapshotDerivedState(); } } catch { // Keep prior state and wait for welcome or a later replay attempt. @@ -407,6 +412,7 @@ function EventRouter() { applyOrchestrationEvents, navigate, queryClient, + removeTerminalState, removeOrphanedTerminalStates, setProjectExpanded, syncServerReadModel, diff --git a/apps/web/src/terminalStateStore.ts b/apps/web/src/terminalStateStore.ts index 4f51e2ed8d..62e0883516 100644 --- a/apps/web/src/terminalStateStore.ts +++ b/apps/web/src/terminalStateStore.ts @@ -485,6 +485,7 @@ interface TerminalStateStoreState { hasRunningSubprocess: boolean, ) => void; clearTerminalState: (threadId: ThreadId) => void; + removeTerminalState: (threadId: ThreadId) => void; removeOrphanedTerminalStates: (activeThreadIds: Set) => void; } @@ -530,6 +531,15 @@ export const useTerminalStateStore = create()( ), clearTerminalState: (threadId) => updateTerminal(threadId, () => createDefaultThreadTerminalState()), + removeTerminalState: (threadId) => + set((state) => { + if (state.terminalStateByThreadId[threadId] === undefined) { + return state; + } + const next = { ...state.terminalStateByThreadId }; + delete next[threadId]; + return { terminalStateByThreadId: next }; + }), removeOrphanedTerminalStates: (activeThreadIds) => set((state) => { const orphanedIds = Object.keys(state.terminalStateByThreadId).filter( From 8ec638c645b31f9d5188a2d900158ef8e43ebc6a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 29 Mar 2026 20:31:07 -0700 Subject: [PATCH 04/22] Separate promoted draft cleanup from deleted thread cleanup - Split orchestration effects for promoted vs deleted threads - Add single-thread draft cleanup helper and update route handling - Cover promotion cleanup behavior with store and effect tests --- apps/web/src/components/ChatView.browser.tsx | 17 +++--- apps/web/src/composerDraftStore.test.ts | 53 +++++++++++++++++++ apps/web/src/composerDraftStore.ts | 25 +++++---- .../web/src/orchestrationEventEffects.test.ts | 6 ++- apps/web/src/orchestrationEventEffects.ts | 25 ++++++--- apps/web/src/routes/__root.tsx | 13 +++-- 6 files changed, 106 insertions(+), 33 deletions(-) diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index be9d5f9ac7..90285da8cc 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -21,7 +21,7 @@ import { page } from "vitest/browser"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { render } from "vitest-browser-react"; -import { useComposerDraftStore } from "../composerDraftStore"; +import { clearPromotedDraftThread, useComposerDraftStore } from "../composerDraftStore"; import { INLINE_TERMINAL_CONTEXT_PLACEHOLDER, type TerminalContextDraft, @@ -1800,21 +1800,20 @@ describe("ChatView timeline estimator parity (full app)", () => { // The composer editor should be present for the new draft thread. await waitForComposerEditor(); - // Simulate the snapshot sync arriving from the server after the draft - // thread has been promoted to a server thread (thread.create + turn.start - // succeeded). The snapshot now includes the new thread, and the sync - // should clear the draft without disrupting the route. + // Simulate the server thread appearing after the draft promotion + // succeeds. Recovery/bootstrapping can still hydrate it via snapshot, + // and clearing the draft should not disrupt the route. const { syncServerReadModel } = useStore.getState(); syncServerReadModel(addThreadToSnapshot(fixture.snapshot, newThreadId)); - // Clear the draft now that the server thread exists (mirrors EventRouter behavior). - useComposerDraftStore.getState().clearDraftThread(newThreadId); + // Clear the draft now that the server thread exists. + clearPromotedDraftThread(newThreadId); // The route should still be on the new thread — not redirected away. await waitForURL( mounted.router, (path) => path === newThreadPath, - "New thread should remain selected after snapshot sync clears the draft.", + "New thread should remain selected after server thread promotion clears the draft.", ); // The empty thread view and composer should still be visible. @@ -2138,7 +2137,7 @@ describe("ChatView timeline estimator parity (full app)", () => { const { syncServerReadModel } = useStore.getState(); syncServerReadModel(addThreadToSnapshot(fixture.snapshot, promotedThreadId)); - useComposerDraftStore.getState().clearDraftThread(promotedThreadId); + clearPromotedDraftThread(promotedThreadId); const freshThreadPath = await triggerChatNewShortcutUntilPath( mounted.router, diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index b68663a890..797e27a6ed 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -9,6 +9,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { COMPOSER_DRAFT_STORAGE_KEY, + clearPromotedDraftThread, + clearPromotedDraftThreads, type ComposerImageAttachment, useComposerDraftStore, } from "./composerDraftStore"; @@ -549,6 +551,57 @@ describe("composerDraftStore project draft thread mapping", () => { expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined(); }); + it("clears a promoted draft by thread id", () => { + const store = useComposerDraftStore.getState(); + store.setProjectDraftThreadId(projectId, threadId); + store.setPrompt(threadId, "promote me"); + + clearPromotedDraftThread(threadId); + + expect(useComposerDraftStore.getState().getDraftThreadByProjectId(projectId)).toBeNull(); + expect(useComposerDraftStore.getState().getDraftThread(threadId)).toBeNull(); + expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined(); + }); + + it("does not clear composer drafts for existing server threads during promotion cleanup", () => { + const store = useComposerDraftStore.getState(); + store.setPrompt(threadId, "keep me"); + + clearPromotedDraftThread(threadId); + + expect(useComposerDraftStore.getState().getDraftThread(threadId)).toBeNull(); + expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.prompt).toBe("keep me"); + }); + + it("clears promoted drafts from an iterable of server thread ids", () => { + const store = useComposerDraftStore.getState(); + store.setProjectDraftThreadId(projectId, threadId); + store.setPrompt(threadId, "promote me"); + store.setProjectDraftThreadId(otherProjectId, otherThreadId); + store.setPrompt(otherThreadId, "keep me"); + + clearPromotedDraftThreads([threadId]); + + expect(useComposerDraftStore.getState().getDraftThread(threadId)).toBeNull(); + expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined(); + expect( + useComposerDraftStore.getState().getDraftThreadByProjectId(otherProjectId)?.threadId, + ).toBe(otherThreadId); + expect(useComposerDraftStore.getState().draftsByThreadId[otherThreadId]?.prompt).toBe( + "keep me", + ); + }); + + it("keeps existing server-thread composer drafts during iterable promotion cleanup", () => { + const store = useComposerDraftStore.getState(); + store.setPrompt(threadId, "keep me"); + + clearPromotedDraftThreads([threadId]); + + expect(useComposerDraftStore.getState().getDraftThread(threadId)).toBeNull(); + expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.prompt).toBe("keep me"); + }); + it("updates branch context on an existing draft thread", () => { const store = useComposerDraftStore.getState(); store.setProjectDraftThreadId(projectId, threadId, { diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 17b06e7bd1..8a93b7b0da 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -2195,18 +2195,21 @@ export function useEffectiveComposerModelState(input: { } /** - * Clear draft threads that have been promoted to server threads. + * Clear a draft thread once the server has materialized the same thread id. * - * Call this after a snapshot sync so the route guard in `_chat.$threadId` - * sees the server thread before the draft is removed — avoids a redirect - * to `/` caused by a gap where neither draft nor server thread exists. + * Use the single-thread helper for live `thread.created` events and the + * iterable helper for bootstrap/recovery paths that discover multiple server + * threads at once. */ -export function clearPromotedDraftThreads(serverThreadIds: ReadonlySet): void { - const store = useComposerDraftStore.getState(); - const draftThreadIds = Object.keys(store.draftThreadsByThreadId) as ThreadId[]; - for (const draftId of draftThreadIds) { - if (serverThreadIds.has(draftId)) { - store.clearDraftThread(draftId); - } +export function clearPromotedDraftThread(threadId: ThreadId): void { + if (!useComposerDraftStore.getState().getDraftThread(threadId)) { + return; + } + useComposerDraftStore.getState().clearDraftThread(threadId); +} + +export function clearPromotedDraftThreads(serverThreadIds: Iterable): void { + for (const threadId of serverThreadIds) { + clearPromotedDraftThread(threadId); } } diff --git a/apps/web/src/orchestrationEventEffects.test.ts b/apps/web/src/orchestrationEventEffects.test.ts index 5a7f387af8..263610bb95 100644 --- a/apps/web/src/orchestrationEventEffects.test.ts +++ b/apps/web/src/orchestrationEventEffects.test.ts @@ -62,7 +62,8 @@ describe("deriveOrchestrationBatchEffects", () => { }), ]); - expect(effects.clearDraftThreadIds).toEqual([createdThreadId, deletedThreadId]); + expect(effects.clearPromotedDraftThreadIds).toEqual([createdThreadId]); + expect(effects.clearDeletedThreadIds).toEqual([deletedThreadId]); expect(effects.removeTerminalStateThreadIds).toEqual([deletedThreadId]); expect(effects.needsProviderInvalidation).toBe(false); }); @@ -99,7 +100,8 @@ describe("deriveOrchestrationBatchEffects", () => { }), ]); - expect(effects.clearDraftThreadIds).toEqual([threadId]); + expect(effects.clearPromotedDraftThreadIds).toEqual([threadId]); + expect(effects.clearDeletedThreadIds).toEqual([]); expect(effects.removeTerminalStateThreadIds).toEqual([]); expect(effects.needsProviderInvalidation).toBe(true); }); diff --git a/apps/web/src/orchestrationEventEffects.ts b/apps/web/src/orchestrationEventEffects.ts index 7f955b786a..d4dda76d9e 100644 --- a/apps/web/src/orchestrationEventEffects.ts +++ b/apps/web/src/orchestrationEventEffects.ts @@ -1,7 +1,8 @@ import type { OrchestrationEvent, ThreadId } from "@t3tools/contracts"; export interface OrchestrationBatchEffects { - clearDraftThreadIds: ThreadId[]; + clearPromotedDraftThreadIds: ThreadId[]; + clearDeletedThreadIds: ThreadId[]; removeTerminalStateThreadIds: ThreadId[]; needsProviderInvalidation: boolean; } @@ -12,7 +13,8 @@ export function deriveOrchestrationBatchEffects( const threadLifecycleEffects = new Map< ThreadId, { - clearDraft: boolean; + clearPromotedDraft: boolean; + clearDeletedThread: boolean; removeTerminalState: boolean; } >(); @@ -28,7 +30,8 @@ export function deriveOrchestrationBatchEffects( case "thread.created": { threadLifecycleEffects.set(event.payload.threadId, { - clearDraft: true, + clearPromotedDraft: true, + clearDeletedThread: false, removeTerminalState: false, }); break; @@ -36,7 +39,8 @@ export function deriveOrchestrationBatchEffects( case "thread.deleted": { threadLifecycleEffects.set(event.payload.threadId, { - clearDraft: true, + clearPromotedDraft: false, + clearDeletedThread: true, removeTerminalState: true, }); break; @@ -48,11 +52,15 @@ export function deriveOrchestrationBatchEffects( } } - const clearDraftThreadIds: ThreadId[] = []; + const clearPromotedDraftThreadIds: ThreadId[] = []; + const clearDeletedThreadIds: ThreadId[] = []; const removeTerminalStateThreadIds: ThreadId[] = []; for (const [threadId, effect] of threadLifecycleEffects) { - if (effect.clearDraft) { - clearDraftThreadIds.push(threadId); + if (effect.clearPromotedDraft) { + clearPromotedDraftThreadIds.push(threadId); + } + if (effect.clearDeletedThread) { + clearDeletedThreadIds.push(threadId); } if (effect.removeTerminalState) { removeTerminalStateThreadIds.push(threadId); @@ -60,7 +68,8 @@ export function deriveOrchestrationBatchEffects( } return { - clearDraftThreadIds, + clearPromotedDraftThreadIds, + clearDeletedThreadIds, removeTerminalStateThreadIds, needsProviderInvalidation, }; diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 4a33021865..37c12484ed 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -17,7 +17,11 @@ import { AnchoredToastProvider, ToastProvider, toastManager } from "../component import { resolveAndPersistPreferredEditor } from "../editorPreferences"; import { serverConfigQueryOptions, serverQueryKeys } from "../lib/serverReactQuery"; import { readNativeApi } from "../nativeApi"; -import { clearPromotedDraftThreads, useComposerDraftStore } from "../composerDraftStore"; +import { + clearPromotedDraftThread, + clearPromotedDraftThreads, + useComposerDraftStore, +} from "../composerDraftStore"; import { useStore } from "../store"; import { useTerminalStateStore } from "../terminalStateStore"; import { terminalRunningSubprocessFromEvent } from "../terminalActivity"; @@ -167,7 +171,7 @@ function EventRouter() { const reconcileSnapshotDerivedState = () => { const threads = useStore.getState().threads; - clearPromotedDraftThreads(new Set(threads.map((thread) => thread.id))); + clearPromotedDraftThreads(threads.map((thread) => thread.id)); const draftThreadIds = Object.keys( useComposerDraftStore.getState().draftThreadsByThreadId, ) as ThreadId[]; @@ -216,7 +220,10 @@ function EventRouter() { applyOrchestrationEvents(nextEvents); const draftStore = useComposerDraftStore.getState(); - for (const threadId of batchEffects.clearDraftThreadIds) { + for (const threadId of batchEffects.clearPromotedDraftThreadIds) { + clearPromotedDraftThread(threadId); + } + for (const threadId of batchEffects.clearDeletedThreadIds) { draftStore.clearDraftThread(threadId); } for (const threadId of batchEffects.removeTerminalStateThreadIds) { From 708762e405d39cf76fa678bae6bbeba52b068779 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 29 Mar 2026 20:43:55 -0700 Subject: [PATCH 05/22] Gate thread redirect on bootstrap completion - Rename store hydration flag to bootstrapComplete - Only clear missing-thread redirects after snapshot sync --- apps/web/src/components/ChatView.browser.tsx | 2 +- .../components/KeybindingsToast.browser.tsx | 2 +- apps/web/src/routes/_chat.$threadId.tsx | 8 ++-- apps/web/src/store.test.ts | 35 ++++++++++++-- apps/web/src/store.ts | 48 +++++++++---------- 5 files changed, 60 insertions(+), 35 deletions(-) diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 90285da8cc..d0a207c027 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -875,7 +875,7 @@ describe("ChatView timeline estimator parity (full app)", () => { useStore.setState({ projects: [], threads: [], - threadsHydrated: false, + bootstrapComplete: false, }); }); diff --git a/apps/web/src/components/KeybindingsToast.browser.tsx b/apps/web/src/components/KeybindingsToast.browser.tsx index 5d6ec94a50..467f74f37f 100644 --- a/apps/web/src/components/KeybindingsToast.browser.tsx +++ b/apps/web/src/components/KeybindingsToast.browser.tsx @@ -323,7 +323,7 @@ describe("Keybindings update toast", () => { useStore.setState({ projects: [], threads: [], - threadsHydrated: false, + bootstrapComplete: false, }); }); diff --git a/apps/web/src/routes/_chat.$threadId.tsx b/apps/web/src/routes/_chat.$threadId.tsx index 8e7a5d3ba8..b95d1ef7b0 100644 --- a/apps/web/src/routes/_chat.$threadId.tsx +++ b/apps/web/src/routes/_chat.$threadId.tsx @@ -161,7 +161,7 @@ const DiffPanelInlineSidebar = (props: { }; function ChatThreadRouteView() { - const threadsHydrated = useStore((store) => store.threadsHydrated); + const bootstrapComplete = useStore((store) => store.bootstrapComplete); const navigate = useNavigate(); const threadId = Route.useParams({ select: (params) => ThreadId.makeUnsafe(params.threadId), @@ -202,7 +202,7 @@ function ChatThreadRouteView() { }, [diffOpen]); useEffect(() => { - if (!threadsHydrated) { + if (!bootstrapComplete) { return; } @@ -210,9 +210,9 @@ function ChatThreadRouteView() { void navigate({ to: "/", replace: true }); return; } - }, [navigate, routeThreadExists, threadsHydrated, threadId]); + }, [bootstrapComplete, navigate, routeThreadExists, threadId]); - if (!threadsHydrated || !routeThreadExists) { + if (!bootstrapComplete || !routeThreadExists) { return null; } diff --git a/apps/web/src/store.test.ts b/apps/web/src/store.test.ts index 46ee2a4323..54eaae7446 100644 --- a/apps/web/src/store.test.ts +++ b/apps/web/src/store.test.ts @@ -64,7 +64,7 @@ function makeState(thread: Thread): AppState { }, ], threads: [thread], - threadsHydrated: true, + bootstrapComplete: true, }; } @@ -245,7 +245,7 @@ describe("store pure functions", () => { }, ], threads: [], - threadsHydrated: true, + bootstrapComplete: true, }; const next = reorderProjects(state, project1, project3); @@ -255,6 +255,17 @@ describe("store pure functions", () => { }); describe("store read model sync", () => { + it("marks bootstrap complete after snapshot sync", () => { + const initialState: AppState = { + ...makeState(makeThread()), + bootstrapComplete: false, + }; + + const next = syncServerReadModel(initialState, makeReadModel(makeReadModelThread({}))); + + expect(next.bootstrapComplete).toBe(true); + }); + it("preserves claude model slugs without an active session", () => { const initialState = makeState(makeThread()); const readModel = makeReadModel( @@ -355,7 +366,7 @@ describe("store read model sync", () => { }, ], threads: [], - threadsHydrated: true, + bootstrapComplete: true, }; const readModel: OrchestrationReadModel = { snapshotSequence: 2, @@ -387,6 +398,24 @@ describe("store read model sync", () => { }); describe("incremental orchestration updates", () => { + it("does not mark bootstrap complete for incremental events", () => { + const state: AppState = { + ...makeState(makeThread()), + bootstrapComplete: false, + }; + + const next = applyOrchestrationEvent( + state, + makeEvent("thread.meta-updated", { + threadId: ThreadId.makeUnsafe("thread-1"), + title: "Updated title", + updatedAt: "2026-02-27T00:00:01.000Z", + }), + ); + + expect(next.bootstrapComplete).toBe(false); + }); + it("preserves state identity for no-op project and thread deletes", () => { const thread = makeThread(); const state = makeState(thread); diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index d1adb8e883..c00bfc35b6 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -21,7 +21,7 @@ import { Debouncer } from "@tanstack/react-pacer"; export interface AppState { projects: Project[]; threads: Thread[]; - threadsHydrated: boolean; + bootstrapComplete: boolean; } const PERSISTED_STATE_KEY = "t3code:renderer-state:v8"; @@ -40,7 +40,7 @@ const LEGACY_PERSISTED_STATE_KEYS = [ const initialState: AppState = { projects: [], threads: [], - threadsHydrated: false, + bootstrapComplete: false, }; const persistedExpandedProjectCwds = new Set(); const persistedProjectOrderCwds: string[] = []; @@ -512,7 +512,7 @@ export function syncServerReadModel(state: AppState, readModel: OrchestrationRea ...state, projects, threads, - threadsHydrated: true, + bootstrapComplete: true, }; } @@ -543,7 +543,7 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve index === existingIndex ? nextProject : project, ) : [...state.projects, nextProject]; - return { ...state, projects, threadsHydrated: true }; + return { ...state, projects }; } case "project.meta-updated": { @@ -563,14 +563,12 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve : {}), updatedAt: event.payload.updatedAt, })); - return projects === state.projects ? state : { ...state, projects, threadsHydrated: true }; + return projects === state.projects ? state : { ...state, projects }; } case "project.deleted": { const projects = state.projects.filter((project) => project.id !== event.payload.projectId); - return projects.length === state.projects.length - ? state - : { ...state, projects, threadsHydrated: true }; + return projects.length === state.projects.length ? state : { ...state, projects }; } case "thread.created": { @@ -601,14 +599,12 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve const threads = existing ? state.threads.map((thread) => (thread.id === nextThread.id ? nextThread : thread)) : [...state.threads, nextThread]; - return { ...state, threads, threadsHydrated: true }; + return { ...state, threads }; } case "thread.deleted": { const threads = state.threads.filter((thread) => thread.id !== event.payload.threadId); - return threads.length === state.threads.length - ? state - : { ...state, threads, threadsHydrated: true }; + return threads.length === state.threads.length ? state : { ...state, threads }; } case "thread.archived": { @@ -617,7 +613,7 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve archivedAt: event.payload.archivedAt, updatedAt: event.payload.updatedAt, })); - return threads === state.threads ? state : { ...state, threads, threadsHydrated: true }; + return threads === state.threads ? state : { ...state, threads }; } case "thread.unarchived": { @@ -626,7 +622,7 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve archivedAt: null, updatedAt: event.payload.updatedAt, })); - return threads === state.threads ? state : { ...state, threads, threadsHydrated: true }; + return threads === state.threads ? state : { ...state, threads }; } case "thread.meta-updated": { @@ -642,7 +638,7 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve : {}), updatedAt: event.payload.updatedAt, })); - return threads === state.threads ? state : { ...state, threads, threadsHydrated: true }; + return threads === state.threads ? state : { ...state, threads }; } case "thread.runtime-mode-set": { @@ -651,7 +647,7 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve runtimeMode: event.payload.runtimeMode, updatedAt: event.payload.updatedAt, })); - return threads === state.threads ? state : { ...state, threads, threadsHydrated: true }; + return threads === state.threads ? state : { ...state, threads }; } case "thread.interaction-mode-set": { @@ -660,7 +656,7 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve interactionMode: event.payload.interactionMode, updatedAt: event.payload.updatedAt, })); - return threads === state.threads ? state : { ...state, threads, threadsHydrated: true }; + return threads === state.threads ? state : { ...state, threads }; } case "thread.turn-start-requested": { @@ -673,7 +669,7 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve interactionMode: event.payload.interactionMode, updatedAt: event.occurredAt, })); - return threads === state.threads ? state : { ...state, threads, threadsHydrated: true }; + return threads === state.threads ? state : { ...state, threads }; } case "thread.turn-interrupt-requested": { @@ -699,7 +695,7 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve updatedAt: event.occurredAt, }; }); - return threads === state.threads ? state : { ...state, threads, threadsHydrated: true }; + return threads === state.threads ? state : { ...state, threads }; } case "thread.message-sent": { @@ -779,7 +775,7 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve updatedAt: event.occurredAt, }; }); - return threads === state.threads ? state : { ...state, threads, threadsHydrated: true }; + return threads === state.threads ? state : { ...state, threads }; } case "thread.session-set": { @@ -810,7 +806,7 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve : thread.latestTurn, updatedAt: event.occurredAt, })); - return threads === state.threads ? state : { ...state, threads, threadsHydrated: true }; + return threads === state.threads ? state : { ...state, threads }; } case "thread.session-stop-requested": { @@ -829,7 +825,7 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve updatedAt: event.occurredAt, }, ); - return threads === state.threads ? state : { ...state, threads, threadsHydrated: true }; + return threads === state.threads ? state : { ...state, threads }; } case "thread.proposed-plan-upserted": { @@ -850,7 +846,7 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve updatedAt: event.occurredAt, }; }); - return threads === state.threads ? state : { ...state, threads, threadsHydrated: true }; + return threads === state.threads ? state : { ...state, threads }; } case "thread.turn-diff-completed": { @@ -899,7 +895,7 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve updatedAt: event.occurredAt, }; }); - return threads === state.threads ? state : { ...state, threads, threadsHydrated: true }; + return threads === state.threads ? state : { ...state, threads }; } case "thread.reverted": { @@ -951,7 +947,7 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve updatedAt: event.occurredAt, }; }); - return threads === state.threads ? state : { ...state, threads, threadsHydrated: true }; + return threads === state.threads ? state : { ...state, threads }; } case "thread.activity-appended": { @@ -968,7 +964,7 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve updatedAt: event.occurredAt, }; }); - return threads === state.threads ? state : { ...state, threads, threadsHydrated: true }; + return threads === state.threads ? state : { ...state, threads }; } case "thread.approval-response-requested": From 1a4d757090b8dc285a00974cefe29a59e927902d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 29 Mar 2026 22:17:03 -0700 Subject: [PATCH 06/22] Move sidebar visit state into a UI store - Split project and thread UI state from server data - Preserve sidebar ordering and unread tracking - Co-authored-by: codex --- apps/web/src/components/ChatView.logic.ts | 1 - apps/web/src/components/ChatView.tsx | 16 +- apps/web/src/components/Sidebar.logic.test.ts | 1 - apps/web/src/components/Sidebar.logic.ts | 6 +- apps/web/src/components/Sidebar.tsx | 86 +++- apps/web/src/hooks/useHandleNewThread.ts | 18 +- apps/web/src/router.ts | 8 +- apps/web/src/routes/__root.tsx | 28 +- apps/web/src/store.test.ts | 141 ++---- apps/web/src/store.ts | 313 ++----------- apps/web/src/types.ts | 2 - apps/web/src/uiStateStore.test.ts | 192 ++++++++ apps/web/src/uiStateStore.ts | 417 ++++++++++++++++++ 13 files changed, 818 insertions(+), 411 deletions(-) create mode 100644 apps/web/src/uiStateStore.test.ts create mode 100644 apps/web/src/uiStateStore.ts diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 0a27fb203e..a808684eb3 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -34,7 +34,6 @@ export function buildLocalDraftThread( createdAt: draftThread.createdAt, archivedAt: null, latestTurn: null, - lastVisitedAt: draftThread.createdAt, branch: draftThread.branch, worktreePath: draftThread.worktreePath, turnDiffSummaries: [], diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index b1e4672e1e..9d9044fe19 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -65,6 +65,7 @@ import { type PendingUserInputDraftAnswer, } from "../pendingUserInput"; import { selectProjectById, selectThreadById, useStore } from "../store"; +import { useUiStateStore } from "../uiStateStore"; import { buildPlanImplementationThreadTitle, buildPlanImplementationPrompt, @@ -273,9 +274,12 @@ interface PendingPullRequestSetupRequest { export default function ChatView({ threadId }: ChatViewProps) { const serverThread = useStore(selectThreadById(threadId)); - const markThreadVisited = useStore((store) => store.markThreadVisited); const setStoreThreadError = useStore((store) => store.setError); const setStoreThreadBranch = useStore((store) => store.setThreadBranch); + const markThreadVisited = useUiStateStore((store) => store.markThreadVisited); + const activeThreadLastVisitedAt = useUiStateStore( + (store) => store.threadLastVisitedAtById[threadId], + ); const settings = useSettings(); const setStickyComposerModelSelection = useComposerDraftStore( (store) => store.setStickyModelSelection, @@ -621,21 +625,21 @@ export default function ChatView({ threadId }: ChatViewProps) { ); useEffect(() => { - if (!activeThread?.id) return; + if (!serverThread?.id) return; if (!latestTurnSettled) return; if (!activeLatestTurn?.completedAt) return; const turnCompletedAt = Date.parse(activeLatestTurn.completedAt); if (Number.isNaN(turnCompletedAt)) return; - const lastVisitedAt = activeThread.lastVisitedAt ? Date.parse(activeThread.lastVisitedAt) : NaN; + const lastVisitedAt = activeThreadLastVisitedAt ? Date.parse(activeThreadLastVisitedAt) : NaN; if (!Number.isNaN(lastVisitedAt) && lastVisitedAt >= turnCompletedAt) return; - markThreadVisited(activeThread.id); + markThreadVisited(serverThread.id); }, [ - activeThread?.id, - activeThread?.lastVisitedAt, activeLatestTurn?.completedAt, + activeThreadLastVisitedAt, latestTurnSettled, markThreadVisited, + serverThread?.id, ]); const sessionProvider = activeThread?.session?.provider ?? null; diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index d7f93f371d..544bd48a5d 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -467,7 +467,6 @@ function makeProject(overrides: Partial = {}): Project { model: "gpt-5.4", ...defaultModelSelection, }, - expanded: true, createdAt: "2026-03-09T10:00:00.000Z", updatedAt: "2026-03-09T10:00:00.000Z", scripts: [], diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index f4508416f9..759c363252 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -46,8 +46,10 @@ const THREAD_STATUS_PRIORITY: Record = { type ThreadStatusInput = Pick< Thread, - "interactionMode" | "latestTurn" | "lastVisitedAt" | "proposedPlans" | "session" ->; + "interactionMode" | "latestTurn" | "proposedPlans" | "session" +> & { + lastVisitedAt?: string | undefined; +}; export function hasUnseenCompletion(thread: ThreadStatusInput): boolean { if (!thread.latestTurn?.completedAt) return false; diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 727353124c..b450493345 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -56,6 +56,7 @@ import { APP_STAGE_LABEL, APP_VERSION } from "../branding"; import { isTerminalFocused } from "../lib/terminalFocus"; import { isLinuxPlatform, isMacPlatform, newCommandId, newProjectId } from "../lib/utils"; import { useStore } from "../store"; +import { useUiStateStore } from "../uiStateStore"; import { resolveShortcutCommand, shortcutLabelForCommand, @@ -121,7 +122,7 @@ import { import { SidebarUpdatePill } from "./sidebar/SidebarUpdatePill"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { useSettings, useUpdateSettings } from "~/hooks/useSettings"; -import type { Thread } from "../types"; +import type { Project, Thread } from "../types"; const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; const THREAD_PREVIEW_LIMIT = 6; @@ -147,7 +148,6 @@ type SidebarThreadSnapshot = Pick< | "createdAt" | "id" | "interactionMode" - | "lastVisitedAt" | "latestTurn" | "projectId" | "proposedPlans" @@ -156,10 +156,18 @@ type SidebarThreadSnapshot = Pick< | "updatedAt" | "worktreePath" > & { + lastVisitedAt?: string | undefined; latestUserMessageAt: string | null; }; -const sidebarThreadSnapshotCache = new WeakMap(); +type SidebarProjectSnapshot = Project & { + expanded: boolean; +}; + +const sidebarThreadSnapshotCache = new WeakMap< + Thread, + { lastVisitedAt?: string | undefined; snapshot: SidebarThreadSnapshot } +>(); function getLatestUserMessageAt(thread: Thread): string | null { let latestUserMessageAt: string | null = null; @@ -176,10 +184,13 @@ function getLatestUserMessageAt(thread: Thread): string | null { return latestUserMessageAt; } -function toSidebarThreadSnapshot(thread: Thread): SidebarThreadSnapshot { +function toSidebarThreadSnapshot( + thread: Thread, + lastVisitedAt: string | undefined, +): SidebarThreadSnapshot { const cached = sidebarThreadSnapshotCache.get(thread); - if (cached) { - return cached; + if (cached && cached.lastVisitedAt === lastVisitedAt) { + return cached.snapshot; } const snapshot: SidebarThreadSnapshot = { @@ -192,14 +203,14 @@ function toSidebarThreadSnapshot(thread: Thread): SidebarThreadSnapshot { updatedAt: thread.updatedAt, archivedAt: thread.archivedAt, latestTurn: thread.latestTurn, - lastVisitedAt: thread.lastVisitedAt, + lastVisitedAt, branch: thread.branch, worktreePath: thread.worktreePath, activities: thread.activities, proposedPlans: thread.proposedPlans, latestUserMessageAt: getLatestUserMessageAt(thread), }; - sidebarThreadSnapshotCache.set(thread, snapshot); + sidebarThreadSnapshotCache.set(thread, { lastVisitedAt, snapshot }); return snapshot; } interface TerminalStatusIndicator { @@ -425,10 +436,17 @@ function SortableProjectItem({ export default function Sidebar() { const projects = useStore(useShallow((store) => store.projects)); - const threads = useStore(useShallow((store) => store.threads.map(toSidebarThreadSnapshot))); - const markThreadUnread = useStore((store) => store.markThreadUnread); - const toggleProject = useStore((store) => store.toggleProject); - const reorderProjects = useStore((store) => store.reorderProjects); + const serverThreads = useStore(useShallow((store) => store.threads)); + const { projectExpandedById, projectOrder, threadLastVisitedAtById } = useUiStateStore( + useShallow((store) => ({ + projectExpandedById: store.projectExpandedById, + projectOrder: store.projectOrder, + threadLastVisitedAtById: store.threadLastVisitedAtById, + })), + ); + const markThreadUnread = useUiStateStore((store) => store.markThreadUnread); + const toggleProject = useUiStateStore((store) => store.toggleProject); + const reorderProjects = useUiStateStore((store) => store.reorderProjects); const clearComposerDraftForThread = useComposerDraftStore((store) => store.clearDraftThread); const getDraftThreadByProjectId = useComposerDraftStore( (store) => store.getDraftThreadByProjectId, @@ -482,6 +500,33 @@ export default function Sidebar() { const platform = navigator.platform; const shouldBrowseForProjectImmediately = isElectron && !isLinuxDesktop; const shouldShowProjectPathEntry = addingProject && !shouldBrowseForProjectImmediately; + const orderedProjects = useMemo(() => { + if (projectOrder.length === 0) { + return projects; + } + const projectsById = new Map(projects.map((project) => [project.id, project] as const)); + const ordered = projectOrder.flatMap((projectId) => { + const project = projectsById.get(projectId); + return project ? [project] : []; + }); + const remaining = projects.filter((project) => !projectOrder.includes(project.id)); + return [...ordered, ...remaining]; + }, [projectOrder, projects]); + const sidebarProjects = useMemo( + () => + orderedProjects.map((project) => ({ + ...project, + expanded: projectExpandedById[project.id] ?? true, + })), + [orderedProjects, projectExpandedById], + ); + const threads = useMemo( + () => + serverThreads.map((thread) => + toSidebarThreadSnapshot(thread, threadLastVisitedAtById[thread.id]), + ), + [serverThreads, threadLastVisitedAtById], + ); const projectCwdById = useMemo( () => new Map(projects.map((project) => [project.id, project.cwd] as const)), [projects], @@ -816,7 +861,7 @@ export default function Sidebar() { } if (clicked === "mark-unread") { - markThreadUnread(threadId); + markThreadUnread(threadId, thread.latestTurn?.completedAt); return; } if (clicked === "copy-path") { @@ -878,7 +923,8 @@ export default function Sidebar() { if (clicked === "mark-unread") { for (const id of ids) { - markThreadUnread(id); + const thread = threads.find((candidate) => candidate.id === id); + markThreadUnread(id, thread?.latestTurn?.completedAt); } clearSelection(); return; @@ -909,6 +955,7 @@ export default function Sidebar() { markThreadUnread, removeFromSelection, selectedThreadIds, + threads, ], ); @@ -1051,12 +1098,12 @@ export default function Sidebar() { dragInProgressRef.current = false; const { active, over } = event; if (!over || active.id === over.id) return; - const activeProject = projects.find((project) => project.id === active.id); - const overProject = projects.find((project) => project.id === over.id); + const activeProject = sidebarProjects.find((project) => project.id === active.id); + const overProject = sidebarProjects.find((project) => project.id === over.id); if (!activeProject || !overProject) return; reorderProjects(activeProject.id, overProject.id); }, - [appSettings.sidebarProjectSortOrder, projects, reorderProjects], + [appSettings.sidebarProjectSortOrder, reorderProjects, sidebarProjects], ); const handleProjectDragStart = useCallback( @@ -1116,8 +1163,9 @@ export default function Sidebar() { [threads], ); const sortedProjects = useMemo( - () => sortProjectsForSidebar(projects, visibleThreads, appSettings.sidebarProjectSortOrder), - [appSettings.sidebarProjectSortOrder, projects, visibleThreads], + () => + sortProjectsForSidebar(sidebarProjects, visibleThreads, appSettings.sidebarProjectSortOrder), + [appSettings.sidebarProjectSortOrder, sidebarProjects, visibleThreads], ); const isManualProjectSorting = appSettings.sidebarProjectSortOrder === "manual"; const renderedProjects = useMemo( diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index ffb4b5cf67..61ec51a5ac 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -1,6 +1,6 @@ import { DEFAULT_RUNTIME_MODE, type ProjectId, ThreadId } from "@t3tools/contracts"; import { useNavigate, useParams } from "@tanstack/react-router"; -import { useCallback } from "react"; +import { useCallback, useMemo } from "react"; import { type DraftThreadEnvMode, type DraftThreadState, @@ -8,10 +8,12 @@ import { } from "../composerDraftStore"; import { newThreadId } from "../lib/utils"; import { useStore } from "../store"; +import { useUiStateStore } from "../uiStateStore"; export function useHandleNewThread() { const projects = useStore((store) => store.projects); const threads = useStore((store) => store.threads); + const projectOrder = useUiStateStore((store) => store.projectOrder); const navigate = useNavigate(); const routeThreadId = useParams({ strict: false, @@ -24,6 +26,18 @@ export function useHandleNewThread() { const activeThread = routeThreadId ? threads.find((thread) => thread.id === routeThreadId) : undefined; + const orderedProjects = useMemo(() => { + if (projectOrder.length === 0) { + return projects; + } + const projectsById = new Map(projects.map((project) => [project.id, project] as const)); + const ordered = projectOrder.flatMap((projectId) => { + const project = projectsById.get(projectId); + return project ? [project] : []; + }); + const remaining = projects.filter((project) => !projectOrder.includes(project.id)); + return [...ordered, ...remaining]; + }, [projectOrder, projects]); const handleNewThread = useCallback( ( @@ -112,7 +126,7 @@ export function useHandleNewThread() { activeDraftThread, activeThread, handleNewThread, - projects, + projects: orderedProjects, routeThreadId, }; } diff --git a/apps/web/src/router.ts b/apps/web/src/router.ts index 0192ee0c6c..0cc711522f 100644 --- a/apps/web/src/router.ts +++ b/apps/web/src/router.ts @@ -3,7 +3,6 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { createRouter } from "@tanstack/react-router"; import { routeTree } from "./routeTree.gen"; -import { StoreProvider } from "./store"; type RouterHistory = NonNullable[0]["history"]>; @@ -16,12 +15,7 @@ export function getRouter(history: RouterHistory) { context: { queryClient, }, - Wrap: ({ children }) => - createElement( - QueryClientProvider, - { client: queryClient }, - createElement(StoreProvider, null, children), - ), + Wrap: ({ children }) => createElement(QueryClientProvider, { client: queryClient }, children), }); } diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 37c12484ed..85071953c6 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -23,6 +23,7 @@ import { useComposerDraftStore, } from "../composerDraftStore"; import { useStore } from "../store"; +import { useUiStateStore } from "../uiStateStore"; import { useTerminalStateStore } from "../terminalStateStore"; import { terminalRunningSubprocessFromEvent } from "../terminalActivity"; import { onServerConfigUpdated, onServerProvidersUpdated, onServerWelcome } from "../wsNativeApi"; @@ -142,7 +143,10 @@ function errorDetails(error: unknown): string { function EventRouter() { const applyOrchestrationEvents = useStore((store) => store.applyOrchestrationEvents); const syncServerReadModel = useStore((store) => store.syncServerReadModel); - const setProjectExpanded = useStore((store) => store.setProjectExpanded); + const setProjectExpanded = useUiStateStore((store) => store.setProjectExpanded); + const syncProjects = useUiStateStore((store) => store.syncProjects); + const syncThreads = useUiStateStore((store) => store.syncThreads); + const clearThreadUi = useUiStateStore((store) => store.clearThreadUi); const removeTerminalState = useTerminalStateStore((store) => store.removeTerminalState); const removeOrphanedTerminalStates = useTerminalStateStore( (store) => store.removeOrphanedTerminalStates, @@ -171,6 +175,14 @@ function EventRouter() { const reconcileSnapshotDerivedState = () => { const threads = useStore.getState().threads; + const projects = useStore.getState().projects; + syncProjects(projects.map((project) => ({ id: project.id, cwd: project.cwd }))); + syncThreads( + threads.map((thread) => ({ + id: thread.id, + seedVisitedAt: thread.updatedAt ?? thread.createdAt, + })), + ); clearPromotedDraftThreads(threads.map((thread) => thread.id)); const draftThreadIds = Object.keys( useComposerDraftStore.getState().draftThreadsByThreadId, @@ -212,6 +224,12 @@ function EventRouter() { highestObservedSequence = Math.max(highestObservedSequence, latestSequence); const batchEffects = deriveOrchestrationBatchEffects(nextEvents); + const needsProjectUiSync = nextEvents.some( + (event) => + event.type === "project.created" || + event.type === "project.meta-updated" || + event.type === "project.deleted", + ); if (batchEffects.needsProviderInvalidation) { needsProviderInvalidation = true; @@ -219,12 +237,17 @@ function EventRouter() { } applyOrchestrationEvents(nextEvents); + if (needsProjectUiSync) { + const projects = useStore.getState().projects; + syncProjects(projects.map((project) => ({ id: project.id, cwd: project.cwd }))); + } const draftStore = useComposerDraftStore.getState(); for (const threadId of batchEffects.clearPromotedDraftThreadIds) { clearPromotedDraftThread(threadId); } for (const threadId of batchEffects.clearDeletedThreadIds) { draftStore.clearDraftThread(threadId); + clearThreadUi(threadId); } for (const threadId of batchEffects.removeTerminalStateThreadIds) { removeTerminalState(threadId); @@ -421,8 +444,11 @@ function EventRouter() { queryClient, removeTerminalState, removeOrphanedTerminalStates, + clearThreadUi, setProjectExpanded, + syncProjects, syncServerReadModel, + syncThreads, ]); return null; diff --git a/apps/web/src/store.test.ts b/apps/web/src/store.test.ts index 54eaae7446..db7d415196 100644 --- a/apps/web/src/store.test.ts +++ b/apps/web/src/store.test.ts @@ -14,8 +14,6 @@ import { describe, expect, it } from "vitest"; import { applyOrchestrationEvent, applyOrchestrationEvents, - markThreadUnread, - reorderProjects, syncServerReadModel, type AppState, } from "./store"; @@ -59,7 +57,6 @@ function makeState(thread: Thread): AppState { provider: "codex", model: "gpt-5-codex", }, - expanded: true, scripts: [], }, ], @@ -164,96 +161,6 @@ function makeReadModelProject( }; } -describe("store pure functions", () => { - it("markThreadUnread moves lastVisitedAt before completion for a completed thread", () => { - const latestTurnCompletedAt = "2026-02-25T12:30:00.000Z"; - const initialState = makeState( - makeThread({ - latestTurn: { - turnId: TurnId.makeUnsafe("turn-1"), - state: "completed", - requestedAt: "2026-02-25T12:28:00.000Z", - startedAt: "2026-02-25T12:28:30.000Z", - completedAt: latestTurnCompletedAt, - assistantMessageId: null, - }, - lastVisitedAt: "2026-02-25T12:35:00.000Z", - }), - ); - - const next = markThreadUnread(initialState, ThreadId.makeUnsafe("thread-1")); - - const updatedThread = next.threads[0]; - expect(updatedThread).toBeDefined(); - expect(updatedThread?.lastVisitedAt).toBe("2026-02-25T12:29:59.999Z"); - expect(Date.parse(updatedThread?.lastVisitedAt ?? "")).toBeLessThan( - Date.parse(latestTurnCompletedAt), - ); - }); - - it("markThreadUnread does not change a thread without a completed turn", () => { - const initialState = makeState( - makeThread({ - latestTurn: null, - lastVisitedAt: "2026-02-25T12:35:00.000Z", - }), - ); - - const next = markThreadUnread(initialState, ThreadId.makeUnsafe("thread-1")); - - expect(next).toEqual(initialState); - }); - - it("reorderProjects moves a project to a target index", () => { - const project1 = ProjectId.makeUnsafe("project-1"); - const project2 = ProjectId.makeUnsafe("project-2"); - const project3 = ProjectId.makeUnsafe("project-3"); - const state: AppState = { - projects: [ - { - id: project1, - name: "Project 1", - cwd: "/tmp/project-1", - defaultModelSelection: { - provider: "codex", - model: DEFAULT_MODEL_BY_PROVIDER.codex, - }, - expanded: true, - scripts: [], - }, - { - id: project2, - name: "Project 2", - cwd: "/tmp/project-2", - defaultModelSelection: { - provider: "codex", - model: DEFAULT_MODEL_BY_PROVIDER.codex, - }, - expanded: true, - scripts: [], - }, - { - id: project3, - name: "Project 3", - cwd: "/tmp/project-3", - defaultModelSelection: { - provider: "codex", - model: DEFAULT_MODEL_BY_PROVIDER.codex, - }, - expanded: true, - scripts: [], - }, - ], - threads: [], - bootstrapComplete: true, - }; - - const next = reorderProjects(state, project1, project3); - - expect(next.projects.map((project) => project.id)).toEqual([project2, project3, project1]); - }); -}); - describe("store read model sync", () => { it("marks bootstrap complete after snapshot sync", () => { const initialState: AppState = { @@ -336,7 +243,7 @@ describe("store read model sync", () => { expect(next.threads[0]?.archivedAt).toBe(archivedAt); }); - it("preserves the current project order when syncing incoming read model updates", () => { + it("replaces projects using snapshot order during recovery", () => { const project1 = ProjectId.makeUnsafe("project-1"); const project2 = ProjectId.makeUnsafe("project-2"); const project3 = ProjectId.makeUnsafe("project-3"); @@ -350,7 +257,6 @@ describe("store read model sync", () => { provider: "codex", model: DEFAULT_MODEL_BY_PROVIDER.codex, }, - expanded: true, scripts: [], }, { @@ -361,7 +267,6 @@ describe("store read model sync", () => { provider: "codex", model: DEFAULT_MODEL_BY_PROVIDER.codex, }, - expanded: true, scripts: [], }, ], @@ -393,7 +298,7 @@ describe("store read model sync", () => { const next = syncServerReadModel(initialState, readModel); - expect(next.projects.map((project) => project.id)).toEqual([project2, project1, project3]); + expect(next.projects.map((project) => project.id)).toEqual([project1, project2, project3]); }); }); @@ -439,6 +344,48 @@ describe("incremental orchestration updates", () => { expect(nextAfterThreadDelete).toBe(state); }); + it("reuses an existing project row when project.created arrives with a new id for the same cwd", () => { + const originalProjectId = ProjectId.makeUnsafe("project-1"); + const recreatedProjectId = ProjectId.makeUnsafe("project-2"); + const state: AppState = { + projects: [ + { + id: originalProjectId, + name: "Project", + cwd: "/tmp/project", + defaultModelSelection: { + provider: "codex", + model: DEFAULT_MODEL_BY_PROVIDER.codex, + }, + scripts: [], + }, + ], + threads: [], + bootstrapComplete: true, + }; + + const next = applyOrchestrationEvent( + state, + makeEvent("project.created", { + projectId: recreatedProjectId, + title: "Project Recreated", + workspaceRoot: "/tmp/project", + defaultModelSelection: { + provider: "codex", + model: DEFAULT_MODEL_BY_PROVIDER.codex, + }, + scripts: [], + createdAt: "2026-02-27T00:00:01.000Z", + updatedAt: "2026-02-27T00:00:01.000Z", + }), + ); + + expect(next.projects).toHaveLength(1); + expect(next.projects[0]?.id).toBe(recreatedProjectId); + expect(next.projects[0]?.cwd).toBe("/tmp/project"); + expect(next.projects[0]?.name).toBe("Project Recreated"); + }); + it("updates only the affected thread for message events", () => { const thread1 = makeThread({ id: ThreadId.makeUnsafe("thread-1"), diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index c00bfc35b6..618842918c 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -1,4 +1,3 @@ -import { Fragment, type ReactNode, createElement, useEffect } from "react"; import { type OrchestrationEvent, type OrchestrationMessage, @@ -14,7 +13,6 @@ import { import { resolveModelSlugForProvider } from "@t3tools/shared/model"; import { create } from "zustand"; import { type ChatMessage, type Project, type Thread } from "./types"; -import { Debouncer } from "@tanstack/react-pacer"; // ── State ──────────────────────────────────────────────────────────── @@ -24,86 +22,16 @@ export interface AppState { bootstrapComplete: boolean; } -const PERSISTED_STATE_KEY = "t3code:renderer-state:v8"; -const LEGACY_PERSISTED_STATE_KEYS = [ - "t3code:renderer-state:v7", - "t3code:renderer-state:v6", - "t3code:renderer-state:v5", - "t3code:renderer-state:v4", - "t3code:renderer-state:v3", - "codething:renderer-state:v4", - "codething:renderer-state:v3", - "codething:renderer-state:v2", - "codething:renderer-state:v1", -] as const; - const initialState: AppState = { projects: [], threads: [], bootstrapComplete: false, }; -const persistedExpandedProjectCwds = new Set(); -const persistedProjectOrderCwds: string[] = []; const MAX_THREAD_MESSAGES = 2_000; const MAX_THREAD_CHECKPOINTS = 500; const MAX_THREAD_PROPOSED_PLANS = 200; const MAX_THREAD_ACTIVITIES = 500; -// ── Persist helpers ────────────────────────────────────────────────── - -function readPersistedState(): AppState { - if (typeof window === "undefined") return initialState; - try { - const raw = window.localStorage.getItem(PERSISTED_STATE_KEY); - if (!raw) return initialState; - const parsed = JSON.parse(raw) as { - expandedProjectCwds?: string[]; - projectOrderCwds?: string[]; - }; - persistedExpandedProjectCwds.clear(); - persistedProjectOrderCwds.length = 0; - for (const cwd of parsed.expandedProjectCwds ?? []) { - if (typeof cwd === "string" && cwd.length > 0) { - persistedExpandedProjectCwds.add(cwd); - } - } - for (const cwd of parsed.projectOrderCwds ?? []) { - if (typeof cwd === "string" && cwd.length > 0 && !persistedProjectOrderCwds.includes(cwd)) { - persistedProjectOrderCwds.push(cwd); - } - } - return { ...initialState }; - } catch { - return initialState; - } -} - -let legacyKeysCleanedUp = false; - -function persistState(state: AppState): void { - if (typeof window === "undefined") return; - try { - window.localStorage.setItem( - PERSISTED_STATE_KEY, - JSON.stringify({ - expandedProjectCwds: state.projects - .filter((project) => project.expanded) - .map((project) => project.cwd), - projectOrderCwds: state.projects.map((project) => project.cwd), - }), - ); - if (!legacyKeysCleanedUp) { - legacyKeysCleanedUp = true; - for (const legacyKey of LEGACY_PERSISTED_STATE_KEYS) { - window.localStorage.removeItem(legacyKey); - } - } - } catch { - // Ignore quota/storage errors to avoid breaking chat UX. - } -} -const debouncedPersistState = new Debouncer(persistState, { wait: 500 }); - // ── Pure helpers ────────────────────────────────────────────────────── function updateThread( @@ -213,7 +141,7 @@ function mapTurnDiffSummary( }; } -function mapThread(thread: OrchestrationThread, existing?: Thread): Thread { +function mapThread(thread: OrchestrationThread): Thread { return { id: thread.id, codexThreadId: null, @@ -230,7 +158,6 @@ function mapThread(thread: OrchestrationThread, existing?: Thread): Thread { archivedAt: thread.archivedAt, updatedAt: thread.updatedAt, latestTurn: thread.latestTurn, - lastVisitedAt: existing?.lastVisitedAt ?? thread.updatedAt, branch: thread.branch, worktreePath: thread.worktreePath, turnDiffSummaries: thread.checkpoints.map(mapTurnDiffSummary), @@ -238,24 +165,14 @@ function mapThread(thread: OrchestrationThread, existing?: Thread): Thread { }; } -function mapProject( - project: OrchestrationReadModel["projects"][number], - existing?: Project, -): Project { +function mapProject(project: OrchestrationReadModel["projects"][number]): Project { return { id: project.id, name: project.title, cwd: project.workspaceRoot, - defaultModelSelection: - existing?.defaultModelSelection ?? - (project.defaultModelSelection - ? normalizeModelSelection(project.defaultModelSelection) - : null), - expanded: - existing?.expanded ?? - (persistedExpandedProjectCwds.size > 0 - ? persistedExpandedProjectCwds.has(project.workspaceRoot) - : true), + defaultModelSelection: project.defaultModelSelection + ? normalizeModelSelection(project.defaultModelSelection) + : null, createdAt: project.createdAt, updatedAt: project.updatedAt, scripts: mapProjectScripts(project.scripts), @@ -400,45 +317,6 @@ function retainThreadProposedPlansAfterRevert( ); } -function mapProjectsFromReadModel( - incoming: OrchestrationReadModel["projects"], - previous: Project[], -): Project[] { - const previousById = new Map(previous.map((project) => [project.id, project] as const)); - const previousByCwd = new Map(previous.map((project) => [project.cwd, project] as const)); - const previousOrderById = new Map(previous.map((project, index) => [project.id, index] as const)); - const previousOrderByCwd = new Map( - previous.map((project, index) => [project.cwd, index] as const), - ); - const persistedOrderByCwd = new Map( - persistedProjectOrderCwds.map((cwd, index) => [cwd, index] as const), - ); - const usePersistedOrder = previous.length === 0; - - const mappedProjects = incoming.map((project) => { - const existing = previousById.get(project.id) ?? previousByCwd.get(project.workspaceRoot); - return mapProject(project, existing); - }); - - return mappedProjects - .map((project, incomingIndex) => { - const previousIndex = - previousOrderById.get(project.id) ?? previousOrderByCwd.get(project.cwd); - const persistedIndex = usePersistedOrder ? persistedOrderByCwd.get(project.cwd) : undefined; - const orderIndex = - previousIndex ?? - persistedIndex ?? - (usePersistedOrder ? persistedProjectOrderCwds.length : previous.length) + incomingIndex; - return { project, incomingIndex, orderIndex }; - }) - .toSorted((a, b) => { - const byOrder = a.orderIndex - b.orderIndex; - if (byOrder !== 0) return byOrder; - return a.incomingIndex - b.incomingIndex; - }) - .map((entry) => entry.project); -} - function toLegacySessionStatus( status: OrchestrationSessionStatus, ): "connecting" | "ready" | "running" | "error" | "closed" { @@ -500,14 +378,10 @@ function attachmentPreviewRoutePath(attachmentId: string): string { // ── Pure state transition functions ──────────────────────────────────── export function syncServerReadModel(state: AppState, readModel: OrchestrationReadModel): AppState { - const projects = mapProjectsFromReadModel( - readModel.projects.filter((project) => project.deletedAt === null), - state.projects, - ); - const existingThreadById = new Map(state.threads.map((thread) => [thread.id, thread] as const)); - const threads = readModel.threads - .filter((thread) => thread.deletedAt === null) - .map((thread) => mapThread(thread, existingThreadById.get(thread.id))); + const projects = readModel.projects + .filter((project) => project.deletedAt === null) + .map(mapProject); + const threads = readModel.threads.filter((thread) => thread.deletedAt === null).map(mapThread); return { ...state, projects, @@ -523,20 +397,16 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve (project) => project.id === event.payload.projectId || project.cwd === event.payload.workspaceRoot, ); - const existing = existingIndex >= 0 ? state.projects[existingIndex] : undefined; - const nextProject = mapProject( - { - id: event.payload.projectId, - title: event.payload.title, - workspaceRoot: event.payload.workspaceRoot, - defaultModelSelection: event.payload.defaultModelSelection, - scripts: event.payload.scripts, - createdAt: event.payload.createdAt, - updatedAt: event.payload.updatedAt, - deletedAt: null, - }, - existing, - ); + const nextProject = mapProject({ + id: event.payload.projectId, + title: event.payload.title, + workspaceRoot: event.payload.workspaceRoot, + defaultModelSelection: event.payload.defaultModelSelection, + scripts: event.payload.scripts, + createdAt: event.payload.createdAt, + updatedAt: event.payload.updatedAt, + deletedAt: null, + }); const projects = existingIndex >= 0 ? state.projects.map((project, index) => @@ -573,29 +443,26 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve case "thread.created": { const existing = state.threads.find((thread) => thread.id === event.payload.threadId); - const nextThread = mapThread( - { - id: event.payload.threadId, - projectId: event.payload.projectId, - title: event.payload.title, - modelSelection: event.payload.modelSelection, - runtimeMode: event.payload.runtimeMode, - interactionMode: event.payload.interactionMode, - branch: event.payload.branch, - worktreePath: event.payload.worktreePath, - latestTurn: null, - createdAt: event.payload.createdAt, - updatedAt: event.payload.updatedAt, - archivedAt: null, - deletedAt: null, - messages: [], - proposedPlans: [], - activities: [], - checkpoints: [], - session: null, - }, - existing, - ); + const nextThread = mapThread({ + id: event.payload.threadId, + projectId: event.payload.projectId, + title: event.payload.title, + modelSelection: event.payload.modelSelection, + runtimeMode: event.payload.runtimeMode, + interactionMode: event.payload.interactionMode, + branch: event.payload.branch, + worktreePath: event.payload.worktreePath, + latestTurn: null, + createdAt: event.payload.createdAt, + updatedAt: event.payload.updatedAt, + archivedAt: null, + deletedAt: null, + messages: [], + proposedPlans: [], + activities: [], + checkpoints: [], + session: null, + }); const threads = existing ? state.threads.map((thread) => (thread.id === nextThread.id ? nextThread : thread)) : [...state.threads, nextThread]; @@ -995,76 +862,6 @@ export const selectThreadById = (state: AppState): Thread | undefined => state.threads.find((thread) => thread.id === threadId); -export function markThreadVisited( - state: AppState, - threadId: ThreadId, - visitedAt?: string, -): AppState { - const at = visitedAt ?? new Date().toISOString(); - const visitedAtMs = Date.parse(at); - const threads = updateThread(state.threads, threadId, (thread) => { - const previousVisitedAtMs = thread.lastVisitedAt ? Date.parse(thread.lastVisitedAt) : NaN; - if ( - Number.isFinite(previousVisitedAtMs) && - Number.isFinite(visitedAtMs) && - previousVisitedAtMs >= visitedAtMs - ) { - return thread; - } - return { ...thread, lastVisitedAt: at }; - }); - return threads === state.threads ? state : { ...state, threads }; -} - -export function markThreadUnread(state: AppState, threadId: ThreadId): AppState { - const threads = updateThread(state.threads, threadId, (thread) => { - if (!thread.latestTurn?.completedAt) return thread; - const latestTurnCompletedAtMs = Date.parse(thread.latestTurn.completedAt); - if (Number.isNaN(latestTurnCompletedAtMs)) return thread; - const unreadVisitedAt = new Date(latestTurnCompletedAtMs - 1).toISOString(); - if (thread.lastVisitedAt === unreadVisitedAt) return thread; - return { ...thread, lastVisitedAt: unreadVisitedAt }; - }); - return threads === state.threads ? state : { ...state, threads }; -} - -export function toggleProject(state: AppState, projectId: Project["id"]): AppState { - return { - ...state, - projects: state.projects.map((p) => (p.id === projectId ? { ...p, expanded: !p.expanded } : p)), - }; -} - -export function setProjectExpanded( - state: AppState, - projectId: Project["id"], - expanded: boolean, -): AppState { - let changed = false; - const projects = state.projects.map((p) => { - if (p.id !== projectId || p.expanded === expanded) return p; - changed = true; - return { ...p, expanded }; - }); - return changed ? { ...state, projects } : state; -} - -export function reorderProjects( - state: AppState, - draggedProjectId: Project["id"], - targetProjectId: Project["id"], -): AppState { - if (draggedProjectId === targetProjectId) return state; - const draggedIndex = state.projects.findIndex((project) => project.id === draggedProjectId); - const targetIndex = state.projects.findIndex((project) => project.id === targetProjectId); - if (draggedIndex < 0 || targetIndex < 0) return state; - const projects = [...state.projects]; - const [draggedProject] = projects.splice(draggedIndex, 1); - if (!draggedProject) return state; - projects.splice(targetIndex, 0, draggedProject); - return { ...state, projects }; -} - export function setError(state: AppState, threadId: ThreadId, error: string | null): AppState { const threads = updateThread(state.threads, threadId, (t) => { if (t.error === error) return t; @@ -1098,46 +895,16 @@ interface AppStore extends AppState { syncServerReadModel: (readModel: OrchestrationReadModel) => void; applyOrchestrationEvent: (event: OrchestrationEvent) => void; applyOrchestrationEvents: (events: ReadonlyArray) => void; - markThreadVisited: (threadId: ThreadId, visitedAt?: string) => void; - markThreadUnread: (threadId: ThreadId) => void; - toggleProject: (projectId: Project["id"]) => void; - setProjectExpanded: (projectId: Project["id"], expanded: boolean) => void; - 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; } export const useStore = create((set) => ({ - ...readPersistedState(), + ...initialState, syncServerReadModel: (readModel) => set((state) => syncServerReadModel(state, readModel)), applyOrchestrationEvent: (event) => set((state) => applyOrchestrationEvent(state, event)), applyOrchestrationEvents: (events) => set((state) => applyOrchestrationEvents(state, events)), - markThreadVisited: (threadId, visitedAt) => - set((state) => markThreadVisited(state, threadId, visitedAt)), - markThreadUnread: (threadId) => set((state) => markThreadUnread(state, threadId)), - toggleProject: (projectId) => set((state) => toggleProject(state, projectId)), - setProjectExpanded: (projectId, expanded) => - set((state) => setProjectExpanded(state, projectId, expanded)), - reorderProjects: (draggedProjectId, targetProjectId) => - set((state) => reorderProjects(state, draggedProjectId, targetProjectId)), setError: (threadId, error) => set((state) => setError(state, threadId, error)), setThreadBranch: (threadId, branch, worktreePath) => set((state) => setThreadBranch(state, threadId, branch, worktreePath)), })); - -// Persist state changes with debouncing to avoid localStorage thrashing -useStore.subscribe((state) => debouncedPersistState.maybeExecute(state)); - -// Flush pending writes synchronously before page unload to prevent data loss. -if (typeof window !== "undefined") { - window.addEventListener("beforeunload", () => { - debouncedPersistState.flush(); - }); -} - -export function StoreProvider({ children }: { children: ReactNode }) { - useEffect(() => { - persistState(useStore.getState()); - }, []); - return createElement(Fragment, null, children); -} diff --git a/apps/web/src/types.ts b/apps/web/src/types.ts index eee7a35529..ec5e01299d 100644 --- a/apps/web/src/types.ts +++ b/apps/web/src/types.ts @@ -83,7 +83,6 @@ export interface Project { name: string; cwd: string; defaultModelSelection: ModelSelection | null; - expanded: boolean; createdAt?: string | undefined; updatedAt?: string | undefined; scripts: ProjectScript[]; @@ -105,7 +104,6 @@ export interface Thread { archivedAt: string | null; updatedAt?: string | undefined; latestTurn: OrchestrationLatestTurn | null; - lastVisitedAt?: string | undefined; branch: string | null; worktreePath: string | null; turnDiffSummaries: TurnDiffSummary[]; diff --git a/apps/web/src/uiStateStore.test.ts b/apps/web/src/uiStateStore.test.ts new file mode 100644 index 0000000000..b0b19f763a --- /dev/null +++ b/apps/web/src/uiStateStore.test.ts @@ -0,0 +1,192 @@ +import { ProjectId, ThreadId } from "@t3tools/contracts"; +import { describe, expect, it } from "vitest"; + +import { + clearThreadUi, + markThreadUnread, + reorderProjects, + setProjectExpanded, + syncProjects, + syncThreads, + type UiState, +} from "./uiStateStore"; + +function makeUiState(overrides: Partial = {}): UiState { + return { + projectExpandedById: {}, + projectOrder: [], + threadLastVisitedAtById: {}, + ...overrides, + }; +} + +describe("uiStateStore pure functions", () => { + it("markThreadUnread moves lastVisitedAt before completion for a completed thread", () => { + const threadId = ThreadId.makeUnsafe("thread-1"); + const latestTurnCompletedAt = "2026-02-25T12:30:00.000Z"; + const initialState = makeUiState({ + threadLastVisitedAtById: { + [threadId]: "2026-02-25T12:35:00.000Z", + }, + }); + + const next = markThreadUnread(initialState, threadId, latestTurnCompletedAt); + + expect(next.threadLastVisitedAtById[threadId]).toBe("2026-02-25T12:29:59.999Z"); + }); + + it("markThreadUnread does not change a thread without a completed turn", () => { + const threadId = ThreadId.makeUnsafe("thread-1"); + const initialState = makeUiState({ + threadLastVisitedAtById: { + [threadId]: "2026-02-25T12:35:00.000Z", + }, + }); + + const next = markThreadUnread(initialState, threadId, null); + + expect(next).toBe(initialState); + }); + + it("reorderProjects moves a project to a target index", () => { + const project1 = ProjectId.makeUnsafe("project-1"); + const project2 = ProjectId.makeUnsafe("project-2"); + const project3 = ProjectId.makeUnsafe("project-3"); + const initialState = makeUiState({ + projectOrder: [project1, project2, project3], + }); + + const next = reorderProjects(initialState, project1, project3); + + expect(next.projectOrder).toEqual([project2, project3, project1]); + }); + + it("syncProjects preserves current project order during snapshot recovery", () => { + const project1 = ProjectId.makeUnsafe("project-1"); + const project2 = ProjectId.makeUnsafe("project-2"); + const project3 = ProjectId.makeUnsafe("project-3"); + const initialState = makeUiState({ + projectExpandedById: { + [project1]: true, + [project2]: false, + }, + projectOrder: [project2, project1], + }); + + const next = syncProjects(initialState, [ + { id: project1, cwd: "/tmp/project-1" }, + { id: project2, cwd: "/tmp/project-2" }, + { id: project3, cwd: "/tmp/project-3" }, + ]); + + expect(next.projectOrder).toEqual([project2, project1, project3]); + expect(next.projectExpandedById[project2]).toBe(false); + }); + + it("syncProjects preserves manual order when a project is recreated with the same cwd", () => { + const oldProject1 = ProjectId.makeUnsafe("project-1"); + const oldProject2 = ProjectId.makeUnsafe("project-2"); + const recreatedProject2 = ProjectId.makeUnsafe("project-2b"); + const initialState = syncProjects( + makeUiState({ + projectExpandedById: { + [oldProject1]: true, + [oldProject2]: false, + }, + projectOrder: [oldProject2, oldProject1], + }), + [ + { id: oldProject1, cwd: "/tmp/project-1" }, + { id: oldProject2, cwd: "/tmp/project-2" }, + ], + ); + + const next = syncProjects(initialState, [ + { id: oldProject1, cwd: "/tmp/project-1" }, + { id: recreatedProject2, cwd: "/tmp/project-2" }, + ]); + + expect(next.projectOrder).toEqual([recreatedProject2, oldProject1]); + expect(next.projectExpandedById[recreatedProject2]).toBe(false); + }); + + it("syncProjects returns a new state when only project cwd changes", () => { + const project1 = ProjectId.makeUnsafe("project-1"); + const initialState = syncProjects( + makeUiState({ + projectExpandedById: { + [project1]: false, + }, + projectOrder: [project1], + }), + [{ id: project1, cwd: "/tmp/project-1" }], + ); + + const next = syncProjects(initialState, [{ id: project1, cwd: "/tmp/project-1-renamed" }]); + + expect(next).not.toBe(initialState); + expect(next.projectOrder).toEqual([project1]); + expect(next.projectExpandedById[project1]).toBe(false); + }); + + it("syncThreads prunes missing thread UI state", () => { + const thread1 = ThreadId.makeUnsafe("thread-1"); + const thread2 = ThreadId.makeUnsafe("thread-2"); + const initialState = makeUiState({ + threadLastVisitedAtById: { + [thread1]: "2026-02-25T12:35:00.000Z", + [thread2]: "2026-02-25T12:36:00.000Z", + }, + }); + + const next = syncThreads(initialState, [{ id: thread1 }]); + + expect(next.threadLastVisitedAtById).toEqual({ + [thread1]: "2026-02-25T12:35:00.000Z", + }); + }); + + it("syncThreads seeds visit state for unseen snapshot threads", () => { + const thread1 = ThreadId.makeUnsafe("thread-1"); + const initialState = makeUiState(); + + const next = syncThreads(initialState, [ + { + id: thread1, + seedVisitedAt: "2026-02-25T12:35:00.000Z", + }, + ]); + + expect(next.threadLastVisitedAtById).toEqual({ + [thread1]: "2026-02-25T12:35:00.000Z", + }); + }); + + it("setProjectExpanded updates expansion without touching order", () => { + const project1 = ProjectId.makeUnsafe("project-1"); + const initialState = makeUiState({ + projectExpandedById: { + [project1]: true, + }, + projectOrder: [project1], + }); + + const next = setProjectExpanded(initialState, project1, false); + + expect(next.projectExpandedById[project1]).toBe(false); + expect(next.projectOrder).toEqual([project1]); + }); + + it("clearThreadUi removes visit state for deleted threads", () => { + const thread1 = ThreadId.makeUnsafe("thread-1"); + const initialState = makeUiState({ + threadLastVisitedAtById: { + [thread1]: "2026-02-25T12:35:00.000Z", + }, + }); + + const next = clearThreadUi(initialState, thread1); + + expect(next.threadLastVisitedAtById).toEqual({}); + }); +}); diff --git a/apps/web/src/uiStateStore.ts b/apps/web/src/uiStateStore.ts new file mode 100644 index 0000000000..342f2db18f --- /dev/null +++ b/apps/web/src/uiStateStore.ts @@ -0,0 +1,417 @@ +import { Debouncer } from "@tanstack/react-pacer"; +import { type ProjectId, type ThreadId } from "@t3tools/contracts"; +import { create } from "zustand"; + +const PERSISTED_STATE_KEY = "t3code:ui-state:v1"; +const LEGACY_PERSISTED_STATE_KEYS = [ + "t3code:renderer-state:v8", + "t3code:renderer-state:v7", + "t3code:renderer-state:v6", + "t3code:renderer-state:v5", + "t3code:renderer-state:v4", + "t3code:renderer-state:v3", + "codething:renderer-state:v4", + "codething:renderer-state:v3", + "codething:renderer-state:v2", + "codething:renderer-state:v1", +] as const; + +interface PersistedUiState { + expandedProjectCwds?: string[]; + projectOrderCwds?: string[]; +} + +export interface UiProjectState { + projectExpandedById: Record; + projectOrder: ProjectId[]; +} + +export interface UiThreadState { + threadLastVisitedAtById: Record; +} + +export interface UiState extends UiProjectState, UiThreadState {} + +export interface SyncProjectInput { + id: ProjectId; + cwd: string; +} + +export interface SyncThreadInput { + id: ThreadId; + seedVisitedAt?: string | undefined; +} + +const initialState: UiState = { + projectExpandedById: {}, + projectOrder: [], + threadLastVisitedAtById: {}, +}; + +const persistedExpandedProjectCwds = new Set(); +const persistedProjectOrderCwds: string[] = []; +const currentProjectCwdById = new Map(); +let legacyKeysCleanedUp = false; + +function readPersistedState(): UiState { + if (typeof window === "undefined") { + return initialState; + } + try { + const raw = window.localStorage.getItem(PERSISTED_STATE_KEY); + if (!raw) { + for (const legacyKey of LEGACY_PERSISTED_STATE_KEYS) { + const legacyRaw = window.localStorage.getItem(legacyKey); + if (!legacyRaw) { + continue; + } + hydratePersistedProjectState(JSON.parse(legacyRaw) as PersistedUiState); + return initialState; + } + return initialState; + } + hydratePersistedProjectState(JSON.parse(raw) as PersistedUiState); + return initialState; + } catch { + return initialState; + } +} + +function hydratePersistedProjectState(parsed: PersistedUiState): void { + persistedExpandedProjectCwds.clear(); + persistedProjectOrderCwds.length = 0; + for (const cwd of parsed.expandedProjectCwds ?? []) { + if (typeof cwd === "string" && cwd.length > 0) { + persistedExpandedProjectCwds.add(cwd); + } + } + for (const cwd of parsed.projectOrderCwds ?? []) { + if (typeof cwd === "string" && cwd.length > 0 && !persistedProjectOrderCwds.includes(cwd)) { + persistedProjectOrderCwds.push(cwd); + } + } +} + +function persistState(state: UiState): void { + if (typeof window === "undefined") { + return; + } + try { + const expandedProjectCwds = Object.entries(state.projectExpandedById) + .filter(([, expanded]) => expanded) + .flatMap(([projectId]) => { + const cwd = currentProjectCwdById.get(projectId as ProjectId); + return cwd ? [cwd] : []; + }); + const projectOrderCwds = state.projectOrder.flatMap((projectId) => { + const cwd = currentProjectCwdById.get(projectId); + return cwd ? [cwd] : []; + }); + window.localStorage.setItem( + PERSISTED_STATE_KEY, + JSON.stringify({ + expandedProjectCwds, + projectOrderCwds, + } satisfies PersistedUiState), + ); + if (!legacyKeysCleanedUp) { + legacyKeysCleanedUp = true; + for (const legacyKey of LEGACY_PERSISTED_STATE_KEYS) { + window.localStorage.removeItem(legacyKey); + } + } + } catch { + // Ignore quota/storage errors to avoid breaking chat UX. + } +} + +const debouncedPersistState = new Debouncer(persistState, { wait: 500 }); + +function recordsEqual(left: Record, right: Record): boolean { + const leftEntries = Object.entries(left); + const rightEntries = Object.entries(right); + if (leftEntries.length !== rightEntries.length) { + return false; + } + for (const [key, value] of leftEntries) { + if (right[key] !== value) { + return false; + } + } + return true; +} + +function projectOrdersEqual(left: readonly ProjectId[], right: readonly ProjectId[]): boolean { + return ( + left.length === right.length && left.every((projectId, index) => projectId === right[index]) + ); +} + +export function syncProjects(state: UiState, projects: readonly SyncProjectInput[]): UiState { + const previousProjectCwdById = new Map(currentProjectCwdById); + const previousProjectIdByCwd = new Map( + [...previousProjectCwdById.entries()].map(([projectId, cwd]) => [cwd, projectId] as const), + ); + currentProjectCwdById.clear(); + for (const project of projects) { + currentProjectCwdById.set(project.id, project.cwd); + } + const cwdMappingChanged = + previousProjectCwdById.size !== currentProjectCwdById.size || + projects.some((project) => previousProjectCwdById.get(project.id) !== project.cwd); + + const nextExpandedById: Record = {}; + const previousExpandedById = state.projectExpandedById; + const persistedOrderByCwd = new Map( + persistedProjectOrderCwds.map((cwd, index) => [cwd, index] as const), + ); + const mappedProjects = projects.map((project, index) => { + const previousProjectIdForCwd = previousProjectIdByCwd.get(project.cwd); + const expanded = + previousExpandedById[project.id] ?? + (previousProjectIdForCwd ? previousExpandedById[previousProjectIdForCwd] : undefined) ?? + (persistedExpandedProjectCwds.size > 0 + ? persistedExpandedProjectCwds.has(project.cwd) + : true); + nextExpandedById[project.id] = expanded; + return { + id: project.id, + cwd: project.cwd, + incomingIndex: index, + }; + }); + + const nextProjectOrder = + state.projectOrder.length > 0 + ? (() => { + const nextProjectIdByCwd = new Map( + mappedProjects.map((project) => [project.cwd, project.id] as const), + ); + const usedProjectIds = new Set(); + const orderedProjectIds: ProjectId[] = []; + + for (const projectId of state.projectOrder) { + const matchedProjectId = + (projectId in nextExpandedById ? projectId : undefined) ?? + (() => { + const previousCwd = previousProjectCwdById.get(projectId); + return previousCwd ? nextProjectIdByCwd.get(previousCwd) : undefined; + })(); + if (!matchedProjectId || usedProjectIds.has(matchedProjectId)) { + continue; + } + usedProjectIds.add(matchedProjectId); + orderedProjectIds.push(matchedProjectId); + } + + for (const project of mappedProjects) { + if (usedProjectIds.has(project.id)) { + continue; + } + orderedProjectIds.push(project.id); + } + + return orderedProjectIds; + })() + : mappedProjects + .map((project) => ({ + id: project.id, + incomingIndex: project.incomingIndex, + orderIndex: + persistedOrderByCwd.get(project.cwd) ?? + persistedProjectOrderCwds.length + project.incomingIndex, + })) + .toSorted((left, right) => { + const byOrder = left.orderIndex - right.orderIndex; + if (byOrder !== 0) { + return byOrder; + } + return left.incomingIndex - right.incomingIndex; + }) + .map((project) => project.id); + + if ( + recordsEqual(state.projectExpandedById, nextExpandedById) && + projectOrdersEqual(state.projectOrder, nextProjectOrder) && + !cwdMappingChanged + ) { + return state; + } + + return { + ...state, + projectExpandedById: nextExpandedById, + projectOrder: nextProjectOrder, + }; +} + +export function syncThreads(state: UiState, threads: readonly SyncThreadInput[]): UiState { + const retainedThreadIds = new Set(threads.map((thread) => thread.id)); + const nextThreadLastVisitedAtById = Object.fromEntries( + Object.entries(state.threadLastVisitedAtById).filter(([threadId]) => + retainedThreadIds.has(threadId as ThreadId), + ), + ); + for (const thread of threads) { + if ( + nextThreadLastVisitedAtById[thread.id] === undefined && + thread.seedVisitedAt !== undefined && + thread.seedVisitedAt.length > 0 + ) { + nextThreadLastVisitedAtById[thread.id] = thread.seedVisitedAt; + } + } + if (recordsEqual(state.threadLastVisitedAtById, nextThreadLastVisitedAtById)) { + return state; + } + return { + ...state, + threadLastVisitedAtById: nextThreadLastVisitedAtById, + }; +} + +export function markThreadVisited(state: UiState, threadId: ThreadId, visitedAt?: string): UiState { + const at = visitedAt ?? new Date().toISOString(); + const visitedAtMs = Date.parse(at); + const previousVisitedAt = state.threadLastVisitedAtById[threadId]; + const previousVisitedAtMs = previousVisitedAt ? Date.parse(previousVisitedAt) : NaN; + if ( + Number.isFinite(previousVisitedAtMs) && + Number.isFinite(visitedAtMs) && + previousVisitedAtMs >= visitedAtMs + ) { + return state; + } + return { + ...state, + threadLastVisitedAtById: { + ...state.threadLastVisitedAtById, + [threadId]: at, + }, + }; +} + +export function markThreadUnread( + state: UiState, + threadId: ThreadId, + latestTurnCompletedAt: string | null | undefined, +): UiState { + if (!latestTurnCompletedAt) { + return state; + } + const latestTurnCompletedAtMs = Date.parse(latestTurnCompletedAt); + if (Number.isNaN(latestTurnCompletedAtMs)) { + return state; + } + const unreadVisitedAt = new Date(latestTurnCompletedAtMs - 1).toISOString(); + if (state.threadLastVisitedAtById[threadId] === unreadVisitedAt) { + return state; + } + return { + ...state, + threadLastVisitedAtById: { + ...state.threadLastVisitedAtById, + [threadId]: unreadVisitedAt, + }, + }; +} + +export function clearThreadUi(state: UiState, threadId: ThreadId): UiState { + if (!(threadId in state.threadLastVisitedAtById)) { + return state; + } + const nextThreadLastVisitedAtById = { ...state.threadLastVisitedAtById }; + delete nextThreadLastVisitedAtById[threadId]; + return { + ...state, + threadLastVisitedAtById: nextThreadLastVisitedAtById, + }; +} + +export function toggleProject(state: UiState, projectId: ProjectId): UiState { + const expanded = state.projectExpandedById[projectId] ?? true; + return { + ...state, + projectExpandedById: { + ...state.projectExpandedById, + [projectId]: !expanded, + }, + }; +} + +export function setProjectExpanded( + state: UiState, + projectId: ProjectId, + expanded: boolean, +): UiState { + if ((state.projectExpandedById[projectId] ?? true) === expanded) { + return state; + } + return { + ...state, + projectExpandedById: { + ...state.projectExpandedById, + [projectId]: expanded, + }, + }; +} + +export function reorderProjects( + state: UiState, + draggedProjectId: ProjectId, + targetProjectId: ProjectId, +): UiState { + if (draggedProjectId === targetProjectId) { + return state; + } + const draggedIndex = state.projectOrder.findIndex((projectId) => projectId === draggedProjectId); + const targetIndex = state.projectOrder.findIndex((projectId) => projectId === targetProjectId); + if (draggedIndex < 0 || targetIndex < 0) { + return state; + } + const projectOrder = [...state.projectOrder]; + const [draggedProject] = projectOrder.splice(draggedIndex, 1); + if (!draggedProject) { + return state; + } + projectOrder.splice(targetIndex, 0, draggedProject); + return { + ...state, + projectOrder, + }; +} + +interface UiStateStore extends UiState { + syncProjects: (projects: readonly SyncProjectInput[]) => void; + syncThreads: (threads: readonly SyncThreadInput[]) => void; + markThreadVisited: (threadId: ThreadId, visitedAt?: string) => void; + markThreadUnread: (threadId: ThreadId, latestTurnCompletedAt: string | null | undefined) => void; + clearThreadUi: (threadId: ThreadId) => void; + toggleProject: (projectId: ProjectId) => void; + setProjectExpanded: (projectId: ProjectId, expanded: boolean) => void; + reorderProjects: (draggedProjectId: ProjectId, targetProjectId: ProjectId) => void; +} + +export const useUiStateStore = create((set) => ({ + ...readPersistedState(), + syncProjects: (projects) => set((state) => syncProjects(state, projects)), + syncThreads: (threads) => set((state) => syncThreads(state, threads)), + markThreadVisited: (threadId, visitedAt) => + set((state) => markThreadVisited(state, threadId, visitedAt)), + markThreadUnread: (threadId, latestTurnCompletedAt) => + set((state) => markThreadUnread(state, threadId, latestTurnCompletedAt)), + clearThreadUi: (threadId) => set((state) => clearThreadUi(state, threadId)), + toggleProject: (projectId) => set((state) => toggleProject(state, projectId)), + setProjectExpanded: (projectId, expanded) => + set((state) => setProjectExpanded(state, projectId, expanded)), + reorderProjects: (draggedProjectId, targetProjectId) => + set((state) => reorderProjects(state, draggedProjectId, targetProjectId)), +})); + +useUiStateStore.subscribe((state) => debouncedPersistState.maybeExecute(state)); + +if (typeof window !== "undefined") { + window.addEventListener("beforeunload", () => { + debouncedPersistState.flush(); + }); +} From 9ca7319598676da07e9611c354189fdde4dda666 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 29 Mar 2026 22:29:56 -0700 Subject: [PATCH 07/22] Reduce thread hook re-renders in chat shortcuts - Read threads and projects from store state on demand - Expose a default project id from the new-thread hook - Skip extra work in global chat shortcut handling --- apps/web/src/hooks/useHandleNewThread.ts | 26 +++++++++--------------- apps/web/src/hooks/useThreadActions.ts | 13 +++++------- apps/web/src/routes/_chat.tsx | 25 +++++++++++++---------- apps/web/src/store.ts | 4 ++-- 4 files changed, 31 insertions(+), 37 deletions(-) diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index 61ec51a5ac..e0ad9553b4 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -1,43 +1,37 @@ import { DEFAULT_RUNTIME_MODE, type ProjectId, ThreadId } from "@t3tools/contracts"; import { useNavigate, useParams } from "@tanstack/react-router"; import { useCallback, useMemo } from "react"; +import { useShallow } from "zustand/react/shallow"; import { type DraftThreadEnvMode, type DraftThreadState, useComposerDraftStore, } from "../composerDraftStore"; import { newThreadId } from "../lib/utils"; -import { useStore } from "../store"; +import { selectThreadById, useStore } from "../store"; import { useUiStateStore } from "../uiStateStore"; export function useHandleNewThread() { - const projects = useStore((store) => store.projects); - const threads = useStore((store) => store.threads); + const projectIds = useStore(useShallow((store) => store.projects.map((project) => project.id))); const projectOrder = useUiStateStore((store) => store.projectOrder); const navigate = useNavigate(); const routeThreadId = useParams({ strict: false, select: (params) => (params.threadId ? ThreadId.makeUnsafe(params.threadId) : null), }); + const activeThread = useStore(selectThreadById(routeThreadId)); const activeDraftThread = useComposerDraftStore((store) => routeThreadId ? (store.draftThreadsByThreadId[routeThreadId] ?? null) : null, ); - - const activeThread = routeThreadId - ? threads.find((thread) => thread.id === routeThreadId) - : undefined; const orderedProjects = useMemo(() => { if (projectOrder.length === 0) { - return projects; + return projectIds; } - const projectsById = new Map(projects.map((project) => [project.id, project] as const)); - const ordered = projectOrder.flatMap((projectId) => { - const project = projectsById.get(projectId); - return project ? [project] : []; - }); - const remaining = projects.filter((project) => !projectOrder.includes(project.id)); + const projectIdsSet = new Set(projectIds); + const ordered = projectOrder.filter((projectId) => projectIdsSet.has(projectId)); + const remaining = projectIds.filter((projectId) => !projectOrder.includes(projectId)); return [...ordered, ...remaining]; - }, [projectOrder, projects]); + }, [projectIds, projectOrder]); const handleNewThread = useCallback( ( @@ -125,8 +119,8 @@ export function useHandleNewThread() { return { activeDraftThread, activeThread, + defaultProjectId: orderedProjects[0] ?? null, handleNewThread, - projects: orderedProjects, routeThreadId, }; } diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index 83cfe911fc..d5557b4a96 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -16,8 +16,6 @@ import { toastManager } from "../components/ui/toast"; import { useSettings } from "./useSettings"; export function useThreadActions() { - const threads = useStore((store) => store.threads); - const projects = useStore((store) => store.projects); const appSettings = useSettings(); const clearComposerDraftForThread = useComposerDraftStore((store) => store.clearDraftThread); const clearProjectDraftThreadById = useComposerDraftStore( @@ -37,7 +35,7 @@ export function useThreadActions() { async (threadId: ThreadId) => { const api = readNativeApi(); if (!api) return; - const thread = threads.find((entry) => entry.id === threadId); + const thread = useStore.getState().threads.find((entry) => entry.id === threadId); if (!thread) return; if (thread.session?.status === "running" && thread.session.activeTurnId != null) { throw new Error("Cannot archive a running thread."); @@ -53,7 +51,7 @@ export function useThreadActions() { await handleNewThread(thread.projectId); } }, - [handleNewThread, routeThreadId, threads], + [handleNewThread, routeThreadId], ); const unarchiveThread = useCallback(async (threadId: ThreadId) => { @@ -70,6 +68,7 @@ export function useThreadActions() { async (threadId: ThreadId, opts: { deletedThreadIds?: ReadonlySet } = {}) => { const api = readNativeApi(); if (!api) return; + const { projects, threads } = useStore.getState(); const thread = threads.find((entry) => entry.id === threadId); if (!thread) return; const threadProject = projects.find((project) => project.id === thread.projectId); @@ -171,10 +170,8 @@ export function useThreadActions() { clearTerminalState, appSettings.sidebarThreadSortOrder, navigate, - projects, removeWorktreeMutation, routeThreadId, - threads, ], ); @@ -182,7 +179,7 @@ export function useThreadActions() { async (threadId: ThreadId) => { const api = readNativeApi(); if (!api) return; - const thread = threads.find((entry) => entry.id === threadId); + const thread = useStore.getState().threads.find((entry) => entry.id === threadId); if (!thread) return; if (appSettings.confirmThreadDelete) { @@ -199,7 +196,7 @@ export function useThreadActions() { await deleteThread(threadId); }, - [appSettings.confirmThreadDelete, deleteThread, threads], + [appSettings.confirmThreadDelete, deleteThread], ); return { diff --git a/apps/web/src/routes/_chat.tsx b/apps/web/src/routes/_chat.tsx index 3c86ab42f3..245ed9c576 100644 --- a/apps/web/src/routes/_chat.tsx +++ b/apps/web/src/routes/_chat.tsx @@ -17,7 +17,7 @@ const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; function ChatRouteGlobalShortcuts() { const clearSelection = useThreadSelectionStore((state) => state.clearSelection); const selectedThreadIdsSize = useThreadSelectionStore((state) => state.selectedThreadIds.size); - const { activeDraftThread, activeThread, handleNewThread, projects, routeThreadId } = + const { activeDraftThread, activeThread, defaultProjectId, handleNewThread, routeThreadId } = useHandleNewThread(); const serverConfigQuery = useQuery(serverConfigQueryOptions()); const keybindings = serverConfigQuery.data?.keybindings ?? EMPTY_KEYBINDINGS; @@ -38,7 +38,7 @@ function ChatRouteGlobalShortcuts() { return; } - const projectId = activeThread?.projectId ?? activeDraftThread?.projectId ?? projects[0]?.id; + const projectId = activeThread?.projectId ?? activeDraftThread?.projectId ?? defaultProjectId; if (!projectId) return; const command = resolveShortcutCommand(event, keybindings, { @@ -59,14 +59,17 @@ function ChatRouteGlobalShortcuts() { return; } - if (command !== "chat.new") return; - event.preventDefault(); - event.stopPropagation(); - void handleNewThread(projectId, { - branch: activeThread?.branch ?? activeDraftThread?.branch ?? null, - worktreePath: activeThread?.worktreePath ?? activeDraftThread?.worktreePath ?? null, - envMode: activeDraftThread?.envMode ?? (activeThread?.worktreePath ? "worktree" : "local"), - }); + if (command === "chat.new") { + event.preventDefault(); + event.stopPropagation(); + void handleNewThread(projectId, { + branch: activeThread?.branch ?? activeDraftThread?.branch ?? null, + worktreePath: activeThread?.worktreePath ?? activeDraftThread?.worktreePath ?? null, + envMode: + activeDraftThread?.envMode ?? (activeThread?.worktreePath ? "worktree" : "local"), + }); + return; + } }; window.addEventListener("keydown", onWindowKeyDown); @@ -79,7 +82,7 @@ function ChatRouteGlobalShortcuts() { clearSelection, handleNewThread, keybindings, - projects, + defaultProjectId, selectedThreadIdsSize, terminalOpen, appSettings.defaultThreadEnvMode, diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index 618842918c..40c80b12c7 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -858,9 +858,9 @@ export const selectProjectById = projectId ? state.projects.find((project) => project.id === projectId) : undefined; export const selectThreadById = - (threadId: ThreadId) => + (threadId: ThreadId | null | undefined) => (state: AppState): Thread | undefined => - state.threads.find((thread) => thread.id === threadId); + threadId ? state.threads.find((thread) => thread.id === threadId) : undefined; export function setError(state: AppState, threadId: ThreadId, error: string | null): AppState { const threads = updateThread(state.threads, threadId, (t) => { From bbb57428b52b5709d2e6c9d054f558504b1b71a7 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 29 Mar 2026 22:40:24 -0700 Subject: [PATCH 08/22] Drive draft promotion through domain events - Simulate thread.created pushes in browser tests - Verify promoted drafts clear via live batch effects --- apps/web/src/components/ChatView.browser.tsx | 100 ++++++++++++++++--- 1 file changed, 87 insertions(+), 13 deletions(-) diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index d0a207c027..5d01379010 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -2,8 +2,11 @@ import "../index.css"; import { + EventId, ORCHESTRATION_WS_METHODS, + ORCHESTRATION_WS_CHANNELS, type MessageId, + type OrchestrationEvent, type OrchestrationReadModel, type ProjectId, type ServerConfig, @@ -21,7 +24,7 @@ import { page } from "vitest/browser"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { render } from "vitest-browser-react"; -import { clearPromotedDraftThread, useComposerDraftStore } from "../composerDraftStore"; +import { useComposerDraftStore } from "../composerDraftStore"; import { INLINE_TERMINAL_CONTEXT_PLACEHOLDER, type TerminalContextDraft, @@ -57,6 +60,8 @@ interface TestFixture { let fixture: TestFixture; const wsRequests: WsRequestEnvelope["body"][] = []; let customWsRpcResolver: ((body: WsRequestEnvelope["body"]) => unknown | undefined) | null = null; +let wsClient: { send: (message: string) => void } | null = null; +let pushSequence = 1; const wsLink = ws.link(/ws(s)?:\/\/.*/); interface ViewportSpec { @@ -336,6 +341,79 @@ function addThreadToSnapshot( }; } +function createThreadCreatedEvent(threadId: ThreadId, sequence: number): OrchestrationEvent { + return { + sequence, + eventId: EventId.makeUnsafe(`event-thread-created-${sequence}`), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: NOW_ISO, + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + type: "thread.created", + payload: { + threadId, + projectId: PROJECT_ID, + title: "New thread", + modelSelection: { + provider: "codex", + model: "gpt-5", + }, + runtimeMode: "full-access", + interactionMode: "default", + branch: "main", + worktreePath: null, + createdAt: NOW_ISO, + updatedAt: NOW_ISO, + }, + }; +} + +function sendOrchestrationDomainEvent(event: OrchestrationEvent): void { + if (!wsClient) { + throw new Error("WebSocket client not connected"); + } + wsClient.send( + JSON.stringify({ + type: "push", + sequence: pushSequence++, + channel: ORCHESTRATION_WS_CHANNELS.domainEvent, + data: event, + }), + ); +} + +async function waitForWsClient(): Promise<{ send: (message: string) => void }> { + let client: { send: (message: string) => void } | null = null; + await vi.waitFor( + () => { + client = wsClient; + expect(client).toBeTruthy(); + }, + { timeout: 8_000, interval: 16 }, + ); + if (!client) { + throw new Error("WebSocket client not connected"); + } + return client; +} + +async function promoteDraftThreadViaDomainEvent(threadId: ThreadId): Promise { + await waitForWsClient(); + fixture.snapshot = addThreadToSnapshot(fixture.snapshot, threadId); + sendOrchestrationDomainEvent( + createThreadCreatedEvent(threadId, fixture.snapshot.snapshotSequence), + ); + await vi.waitFor( + () => { + expect(useComposerDraftStore.getState().draftThreadsByThreadId[threadId]).toBeUndefined(); + }, + { timeout: 8_000, interval: 16 }, + ); +} + function createDraftOnlySnapshot(): OrchestrationReadModel { const snapshot = createSnapshotForTargetUser({ targetMessageId: "msg-user-draft-target" as MessageId, @@ -500,10 +578,12 @@ function resolveWsRpc(body: WsRequestEnvelope["body"]): unknown { const worker = setupWorker( wsLink.addEventListener("connection", ({ client }) => { + wsClient = client; + pushSequence = 1; client.send( JSON.stringify({ type: "push", - sequence: 1, + sequence: pushSequence++, channel: WS_CHANNELS.serverWelcome, data: fixture.welcome, }), @@ -1800,14 +1880,10 @@ describe("ChatView timeline estimator parity (full app)", () => { // The composer editor should be present for the new draft thread. await waitForComposerEditor(); - // Simulate the server thread appearing after the draft promotion - // succeeds. Recovery/bootstrapping can still hydrate it via snapshot, - // and clearing the draft should not disrupt the route. - const { syncServerReadModel } = useStore.getState(); - syncServerReadModel(addThreadToSnapshot(fixture.snapshot, newThreadId)); - - // Clear the draft now that the server thread exists. - clearPromotedDraftThread(newThreadId); + // Simulate the steady-state promotion path: the server emits + // `thread.created`, the client materializes the thread incrementally, + // and the draft is cleared by live batch effects. + await promoteDraftThreadViaDomainEvent(newThreadId); // The route should still be on the new thread — not redirected away. await waitForURL( @@ -2135,9 +2211,7 @@ describe("ChatView timeline estimator parity (full app)", () => { ); const promotedThreadId = promotedThreadPath.slice(1) as ThreadId; - const { syncServerReadModel } = useStore.getState(); - syncServerReadModel(addThreadToSnapshot(fixture.snapshot, promotedThreadId)); - clearPromotedDraftThread(promotedThreadId); + await promoteDraftThreadViaDomainEvent(promotedThreadId); const freshThreadPath = await triggerChatNewShortcutUntilPath( mounted.router, From eb180fa7e5ca1ba3108376de29ff4485b6d13565 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 29 Mar 2026 23:08:02 -0700 Subject: [PATCH 09/22] Track local dispatch until server acknowledges - Replace transient send phase state with local dispatch snapshots - Clear the busy state only after the server reflects the turn/session update - Cover the acknowledgment rules with logic tests --- .../web/src/components/ChatView.logic.test.ts | 149 +++++++++++++++- apps/web/src/components/ChatView.logic.ts | 69 +++++++- apps/web/src/components/ChatView.tsx | 164 +++++++++++------- 3 files changed, 318 insertions(+), 64 deletions(-) diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index bf72ec0b84..cf77c44eca 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -1,7 +1,12 @@ -import { ThreadId } from "@t3tools/contracts"; +import { ProjectId, ThreadId, TurnId } from "@t3tools/contracts"; import { describe, expect, it } from "vitest"; -import { buildExpiredTerminalContextToastCopy, deriveComposerSendState } from "./ChatView.logic"; +import { + buildExpiredTerminalContextToastCopy, + createLocalDispatchSnapshot, + deriveComposerSendState, + hasServerAcknowledgedLocalDispatch, +} from "./ChatView.logic"; describe("deriveComposerSendState", () => { it("treats expired terminal pills as non-sendable content", () => { @@ -67,3 +72,143 @@ describe("buildExpiredTerminalContextToastCopy", () => { }); }); }); + +describe("hasServerAcknowledgedLocalDispatch", () => { + const projectId = ProjectId.makeUnsafe("project-1"); + const previousLatestTurn = { + turnId: TurnId.makeUnsafe("turn-1"), + state: "completed" as const, + requestedAt: "2026-03-29T00:00:00.000Z", + startedAt: "2026-03-29T00:00:01.000Z", + completedAt: "2026-03-29T00:00:10.000Z", + assistantMessageId: null, + }; + + const previousSession = { + provider: "codex" as const, + status: "ready" as const, + createdAt: "2026-03-29T00:00:00.000Z", + updatedAt: "2026-03-29T00:00:10.000Z", + orchestrationStatus: "idle" as const, + }; + + it("does not clear local dispatch before server state changes", () => { + const localDispatch = createLocalDispatchSnapshot({ + id: ThreadId.makeUnsafe("thread-1"), + codexThreadId: null, + projectId, + title: "Thread", + modelSelection: { provider: "codex", model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + session: previousSession, + messages: [], + proposedPlans: [], + error: null, + createdAt: "2026-03-29T00:00:00.000Z", + archivedAt: null, + updatedAt: "2026-03-29T00:00:10.000Z", + latestTurn: previousLatestTurn, + branch: null, + worktreePath: null, + turnDiffSummaries: [], + activities: [], + }); + + expect( + hasServerAcknowledgedLocalDispatch({ + localDispatch, + phase: "ready", + latestTurn: previousLatestTurn, + session: previousSession, + hasPendingApproval: false, + hasPendingUserInput: false, + threadError: null, + }), + ).toBe(false); + }); + + it("clears local dispatch when a new turn is already settled", () => { + const localDispatch = createLocalDispatchSnapshot({ + id: ThreadId.makeUnsafe("thread-1"), + codexThreadId: null, + projectId, + title: "Thread", + modelSelection: { provider: "codex", model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + session: previousSession, + messages: [], + proposedPlans: [], + error: null, + createdAt: "2026-03-29T00:00:00.000Z", + archivedAt: null, + updatedAt: "2026-03-29T00:00:10.000Z", + latestTurn: previousLatestTurn, + branch: null, + worktreePath: null, + turnDiffSummaries: [], + activities: [], + }); + + expect( + hasServerAcknowledgedLocalDispatch({ + localDispatch, + phase: "ready", + latestTurn: { + ...previousLatestTurn, + turnId: TurnId.makeUnsafe("turn-2"), + requestedAt: "2026-03-29T00:01:00.000Z", + startedAt: "2026-03-29T00:01:01.000Z", + completedAt: "2026-03-29T00:01:30.000Z", + }, + session: { + ...previousSession, + updatedAt: "2026-03-29T00:01:30.000Z", + }, + hasPendingApproval: false, + hasPendingUserInput: false, + threadError: null, + }), + ).toBe(true); + }); + + it("clears local dispatch when the session changes without an observed running phase", () => { + const localDispatch = createLocalDispatchSnapshot({ + id: ThreadId.makeUnsafe("thread-1"), + codexThreadId: null, + projectId, + title: "Thread", + modelSelection: { provider: "codex", model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + session: previousSession, + messages: [], + proposedPlans: [], + error: null, + createdAt: "2026-03-29T00:00:00.000Z", + archivedAt: null, + updatedAt: "2026-03-29T00:00:10.000Z", + latestTurn: previousLatestTurn, + branch: null, + worktreePath: null, + turnDiffSummaries: [], + activities: [], + }); + + expect( + hasServerAcknowledgedLocalDispatch({ + localDispatch, + phase: "ready", + latestTurn: previousLatestTurn, + session: { + ...previousSession, + updatedAt: "2026-03-29T00:00:11.000Z", + }, + hasPendingApproval: false, + hasPendingUserInput: false, + threadError: null, + }), + ).toBe(true); + }); +}); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index a808684eb3..a8d339d9d5 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -1,5 +1,5 @@ -import { ProjectId, type ModelSelection, type ThreadId } from "@t3tools/contracts"; -import { type ChatMessage, type Thread } from "../types"; +import { ProjectId, type ModelSelection, type ThreadId, type TurnId } from "@t3tools/contracts"; +import { type ChatMessage, type SessionPhase, type Thread, type ThreadSession } from "../types"; import { randomUUID } from "~/lib/utils"; import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore"; import { Schema } from "effect"; @@ -74,8 +74,6 @@ export function collectUserMessageBlobPreviewUrls(message: ChatMessage): string[ return previewUrls; } -export type SendPhase = "idle" | "preparing-worktree" | "sending-turn"; - export interface PullRequestDialogState { initialReference: string | null; key: number; @@ -160,3 +158,66 @@ export function buildExpiredTerminalContextToastCopy( description: "Re-add it if you want that terminal output included.", }; } + +export interface LocalDispatchSnapshot { + startedAt: string; + preparingWorktree: boolean; + latestTurnTurnId: TurnId | null; + latestTurnRequestedAt: string | null; + latestTurnStartedAt: string | null; + latestTurnCompletedAt: string | null; + sessionOrchestrationStatus: ThreadSession["orchestrationStatus"] | null; + sessionUpdatedAt: string | null; +} + +export function createLocalDispatchSnapshot( + activeThread: Thread | undefined, + options?: { preparingWorktree?: boolean }, +): LocalDispatchSnapshot { + const latestTurn = activeThread?.latestTurn ?? null; + const session = activeThread?.session ?? null; + return { + startedAt: new Date().toISOString(), + preparingWorktree: Boolean(options?.preparingWorktree), + latestTurnTurnId: latestTurn?.turnId ?? null, + latestTurnRequestedAt: latestTurn?.requestedAt ?? null, + latestTurnStartedAt: latestTurn?.startedAt ?? null, + latestTurnCompletedAt: latestTurn?.completedAt ?? null, + sessionOrchestrationStatus: session?.orchestrationStatus ?? null, + sessionUpdatedAt: session?.updatedAt ?? null, + }; +} + +export function hasServerAcknowledgedLocalDispatch(input: { + localDispatch: LocalDispatchSnapshot | null; + phase: SessionPhase; + latestTurn: Thread["latestTurn"] | null; + session: Thread["session"] | null; + hasPendingApproval: boolean; + hasPendingUserInput: boolean; + threadError: string | null | undefined; +}): boolean { + if (!input.localDispatch) { + return false; + } + if ( + input.phase === "running" || + input.hasPendingApproval || + input.hasPendingUserInput || + Boolean(input.threadError) + ) { + return true; + } + + const latestTurn = input.latestTurn ?? null; + const session = input.session ?? null; + + return ( + input.localDispatch.latestTurnTurnId !== (latestTurn?.turnId ?? null) || + input.localDispatch.latestTurnRequestedAt !== (latestTurn?.requestedAt ?? null) || + input.localDispatch.latestTurnStartedAt !== (latestTurn?.startedAt ?? null) || + input.localDispatch.latestTurnCompletedAt !== (latestTurn?.completedAt ?? null) || + input.localDispatch.sessionOrchestrationStatus !== (session?.orchestrationStatus ?? null) || + input.localDispatch.sessionUpdatedAt !== (session?.updatedAt ?? null) + ); +} diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 9d9044fe19..31c12df1f6 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -78,6 +78,7 @@ import { DEFAULT_THREAD_TERMINAL_ID, MAX_TERMINALS_PER_GROUP, type ChatMessage, + type SessionPhase, type Thread, type TurnDiffSummary, } from "../types"; @@ -195,14 +196,16 @@ import { buildTemporaryWorktreeBranchName, cloneComposerImageForRetry, collectUserMessageBlobPreviewUrls, + createLocalDispatchSnapshot, deriveComposerSendState, + hasServerAcknowledgedLocalDispatch, LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, LastInvokedScriptByProjectSchema, + type LocalDispatchSnapshot, PullRequestDialogState, readFileAsDataUrl, revokeBlobPreviewUrl, revokeUserMessagePreviewUrls, - SendPhase, } from "./ChatView.logic"; import { useLocalStorage } from "~/hooks/useLocalStorage"; @@ -272,6 +275,73 @@ interface PendingPullRequestSetupRequest { scriptId: string; } +function useLocalDispatchState(input: { + activeThread: Thread | undefined; + activeLatestTurn: Thread["latestTurn"] | null; + phase: SessionPhase; + activePendingApproval: ApprovalRequestId | null; + activePendingUserInput: ApprovalRequestId | null; + threadError: string | null | undefined; +}) { + const [localDispatch, setLocalDispatch] = useState(null); + + const beginLocalDispatch = useCallback( + (options?: { preparingWorktree?: boolean }) => { + const preparingWorktree = Boolean(options?.preparingWorktree); + setLocalDispatch((current) => { + if (current) { + return current.preparingWorktree === preparingWorktree + ? current + : { ...current, preparingWorktree }; + } + return createLocalDispatchSnapshot(input.activeThread, options); + }); + }, + [input.activeThread], + ); + + const resetLocalDispatch = useCallback(() => { + setLocalDispatch(null); + }, []); + + const serverAcknowledgedLocalDispatch = useMemo( + () => + hasServerAcknowledgedLocalDispatch({ + localDispatch, + phase: input.phase, + latestTurn: input.activeLatestTurn, + session: input.activeThread?.session ?? null, + hasPendingApproval: input.activePendingApproval !== null, + hasPendingUserInput: input.activePendingUserInput !== null, + threadError: input.threadError, + }), + [ + input.activeLatestTurn, + input.activePendingApproval, + input.activePendingUserInput, + input.activeThread?.session, + input.phase, + input.threadError, + localDispatch, + ], + ); + + useEffect(() => { + if (!serverAcknowledgedLocalDispatch) { + return; + } + resetLocalDispatch(); + }, [resetLocalDispatch, serverAcknowledgedLocalDispatch]); + + return { + beginLocalDispatch, + resetLocalDispatch, + localDispatchStartedAt: localDispatch?.startedAt ?? null, + isPreparingWorktree: localDispatch?.preparingWorktree ?? false, + isSendBusy: localDispatch !== null && !serverAcknowledgedLocalDispatch, + }; +} + export default function ChatView({ threadId }: ChatViewProps) { const serverThread = useStore(selectThreadById(threadId)); const setStoreThreadError = useStore((store) => store.setError); @@ -358,8 +428,6 @@ export default function ChatView({ threadId }: ChatViewProps) { const [localDraftErrorsByThreadId, setLocalDraftErrorsByThreadId] = useState< Record >({}); - const [sendPhase, setSendPhase] = useState("idle"); - const [sendStartedAt, setSendStartedAt] = useState(null); const [isConnecting, _setIsConnecting] = useState(false); const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false); const [respondingRequestIds, setRespondingRequestIds] = useState([]); @@ -694,15 +762,6 @@ export default function ChatView({ threadId }: ChatViewProps) { ); const selectedModelForPicker = selectedModel; const phase = derivePhase(activeThread?.session ?? null); - const isSendBusy = sendPhase !== "idle"; - const isPreparingWorktree = sendPhase === "preparing-worktree"; - const isWorking = phase === "running" || isSendBusy || isConnecting || isRevertingCheckpoint; - const nowIso = new Date(nowTick).toISOString(); - const activeWorkStartedAt = deriveActiveWorkStartedAt( - activeLatestTurn, - activeThread?.session ?? null, - sendStartedAt, - ); const threadActivities = activeThread?.activities ?? EMPTY_ACTIVITIES; const workLogEntries = useMemo( () => deriveWorkLogEntries(threadActivities, activeLatestTurn?.turnId ?? undefined), @@ -782,6 +841,27 @@ export default function ChatView({ threadId }: ChatViewProps) { latestTurnSettled && hasActionableProposedPlan(activeProposedPlan); const activePendingApproval = pendingApprovals[0] ?? null; + const { + beginLocalDispatch, + resetLocalDispatch, + localDispatchStartedAt, + isPreparingWorktree, + isSendBusy, + } = useLocalDispatchState({ + activeThread, + activeLatestTurn, + phase, + activePendingApproval: activePendingApproval?.requestId ?? null, + activePendingUserInput: activePendingUserInput?.requestId ?? null, + threadError: activeThread?.error, + }); + const isWorking = phase === "running" || isSendBusy || isConnecting || isRevertingCheckpoint; + const nowIso = new Date(nowTick).toISOString(); + const activeWorkStartedAt = deriveActiveWorkStartedAt( + activeLatestTurn, + activeThread?.session ?? null, + localDispatchStartedAt, + ); const isComposerApprovalState = activePendingApproval !== null; const hasComposerHeader = isComposerApprovalState || @@ -2050,15 +2130,14 @@ export default function ChatView({ threadId }: ChatViewProps) { } return []; }); - setSendPhase("idle"); - setSendStartedAt(null); + resetLocalDispatch(); setComposerHighlightedItemId(null); setComposerCursor(collapseExpandedComposerCursor(promptRef.current, promptRef.current.length)); setComposerTrigger(detectComposerTrigger(promptRef.current, promptRef.current.length)); dragDepthRef.current = 0; setIsDragOverComposer(false); setExpandedImage(null); - }, [threadId]); + }, [resetLocalDispatch, threadId]); useEffect(() => { let cancelled = false; @@ -2191,37 +2270,6 @@ export default function ChatView({ threadId }: ChatViewProps) { }; }, [phase]); - const beginSendPhase = useCallback((nextPhase: Exclude) => { - setSendStartedAt((current) => current ?? new Date().toISOString()); - setSendPhase(nextPhase); - }, []); - - const resetSendPhase = useCallback(() => { - setSendPhase("idle"); - setSendStartedAt(null); - }, []); - - useEffect(() => { - if (sendPhase === "idle") { - return; - } - if ( - phase === "running" || - activePendingApproval !== null || - activePendingUserInput !== null || - activeThread?.error - ) { - resetSendPhase(); - } - }, [ - activePendingApproval, - activePendingUserInput, - activeThread?.error, - phase, - resetSendPhase, - sendPhase, - ]); - useEffect(() => { if (!activeThreadId) return; const previous = terminalOpenByThreadRef.current[activeThreadId] ?? false; @@ -2559,7 +2607,7 @@ export default function ChatView({ threadId }: ChatViewProps) { } sendInFlightRef.current = true; - beginSendPhase(baseBranchForWorktree ? "preparing-worktree" : "sending-turn"); + beginLocalDispatch({ preparingWorktree: Boolean(baseBranchForWorktree) }); const composerImagesSnapshot = [...composerImages]; const composerTerminalContextsSnapshot = [...sendableComposerTerminalContexts]; @@ -2633,7 +2681,7 @@ export default function ChatView({ threadId }: ChatViewProps) { await (async () => { // On first message: lock in branch + create worktree if needed. if (baseBranchForWorktree) { - beginSendPhase("preparing-worktree"); + beginLocalDispatch({ preparingWorktree: true }); const newBranch = buildTemporaryWorktreeBranchName(); const result = await createWorktreeMutation.mutateAsync({ cwd: activeProject.cwd, @@ -2745,7 +2793,7 @@ export default function ChatView({ threadId }: ChatViewProps) { }); } - beginSendPhase("sending-turn"); + beginLocalDispatch({ preparingWorktree: false }); const turnAttachments = await turnAttachmentsPromise; await api.orchestration.dispatchCommand({ type: "thread.turn.start", @@ -2802,7 +2850,7 @@ export default function ChatView({ threadId }: ChatViewProps) { }); sendInFlightRef.current = false; if (!turnStartSucceeded) { - resetSendPhase(); + resetLocalDispatch(); } }; @@ -3001,7 +3049,7 @@ export default function ChatView({ threadId }: ChatViewProps) { }); sendInFlightRef.current = true; - beginSendPhase("sending-turn"); + beginLocalDispatch({ preparingWorktree: false }); setThreadError(threadIdForSend, null); setOptimisticUserMessages((existing) => [ ...existing, @@ -3070,19 +3118,19 @@ export default function ChatView({ threadId }: ChatViewProps) { err instanceof Error ? err.message : "Failed to send plan follow-up.", ); sendInFlightRef.current = false; - resetSendPhase(); + resetLocalDispatch(); } }, [ activeThread, activeProposedPlan, - beginSendPhase, + beginLocalDispatch, forceStickToBottom, isConnecting, isSendBusy, isServerThread, persistThreadSettingsForNextTurn, - resetSendPhase, + resetLocalDispatch, runtimeMode, selectedPromptEffort, selectedModelSelection, @@ -3124,10 +3172,10 @@ export default function ChatView({ threadId }: ChatViewProps) { const nextThreadModelSelection: ModelSelection = selectedModelSelection; sendInFlightRef.current = true; - beginSendPhase("sending-turn"); + beginLocalDispatch({ preparingWorktree: false }); const finish = () => { sendInFlightRef.current = false; - resetSendPhase(); + resetLocalDispatch(); }; await api.orchestration @@ -3190,12 +3238,12 @@ export default function ChatView({ threadId }: ChatViewProps) { activeProject, activeProposedPlan, activeThread, - beginSendPhase, + beginLocalDispatch, isConnecting, isSendBusy, isServerThread, navigate, - resetSendPhase, + resetLocalDispatch, runtimeMode, selectedPromptEffort, selectedModelSelection, From d0823bf373fe8ad2a98757a35c2bd3f26ae2819e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 29 Mar 2026 23:16:01 -0700 Subject: [PATCH 10/22] Extract orchestration recovery coordinator for event replay - Move snapshot/replay sequencing state into a shared coordinator - Add tests for deferred replay, gap recovery, and replay fallback --- apps/web/src/orchestrationRecovery.test.ts | 83 ++++++++++++++ apps/web/src/orchestrationRecovery.ts | 126 +++++++++++++++++++++ apps/web/src/routes/__root.tsx | 88 +++++--------- 3 files changed, 239 insertions(+), 58 deletions(-) create mode 100644 apps/web/src/orchestrationRecovery.test.ts create mode 100644 apps/web/src/orchestrationRecovery.ts diff --git a/apps/web/src/orchestrationRecovery.test.ts b/apps/web/src/orchestrationRecovery.test.ts new file mode 100644 index 0000000000..07b782f9ae --- /dev/null +++ b/apps/web/src/orchestrationRecovery.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from "vitest"; + +import { createOrchestrationRecoveryCoordinator } from "./orchestrationRecovery"; + +describe("createOrchestrationRecoveryCoordinator", () => { + it("defers live events until bootstrap completes and then requests replay", () => { + const coordinator = createOrchestrationRecoveryCoordinator(); + + expect(coordinator.beginSnapshotRecovery("bootstrap")).toBe(true); + expect(coordinator.classifyDomainEvent(4)).toBe("defer"); + + expect(coordinator.completeSnapshotRecovery(2)).toBe(true); + expect(coordinator.getState()).toMatchObject({ + latestSequence: 2, + highestObservedSequence: 4, + bootstrapped: true, + pendingReplay: false, + inFlight: null, + }); + }); + + it("classifies sequence gaps as recovery-only replay work", () => { + const coordinator = createOrchestrationRecoveryCoordinator(); + + coordinator.beginSnapshotRecovery("bootstrap"); + coordinator.completeSnapshotRecovery(3); + + expect(coordinator.classifyDomainEvent(5)).toBe("recover"); + expect(coordinator.beginReplayRecovery("sequence-gap")).toBe(true); + expect(coordinator.getState().inFlight).toEqual({ + kind: "replay", + reason: "sequence-gap", + }); + }); + + it("tracks live event batches without entering recovery", () => { + const coordinator = createOrchestrationRecoveryCoordinator(); + + coordinator.beginSnapshotRecovery("bootstrap"); + coordinator.completeSnapshotRecovery(3); + + expect(coordinator.classifyDomainEvent(4)).toBe("apply"); + expect(coordinator.markEventBatchApplied([{ sequence: 4 }])).toEqual([{ sequence: 4 }]); + expect(coordinator.getState()).toMatchObject({ + latestSequence: 4, + highestObservedSequence: 4, + bootstrapped: true, + inFlight: null, + }); + }); + + it("requests another replay when deferred events arrive during replay recovery", () => { + const coordinator = createOrchestrationRecoveryCoordinator(); + + coordinator.beginSnapshotRecovery("bootstrap"); + coordinator.completeSnapshotRecovery(3); + coordinator.classifyDomainEvent(5); + coordinator.beginReplayRecovery("sequence-gap"); + coordinator.classifyDomainEvent(7); + coordinator.markEventBatchApplied([{ sequence: 4 }, { sequence: 5 }, { sequence: 6 }]); + + expect(coordinator.completeReplayRecovery()).toBe(true); + }); + + it("marks replay failure as unbootstrapped so snapshot fallback is recovery-only", () => { + const coordinator = createOrchestrationRecoveryCoordinator(); + + coordinator.beginSnapshotRecovery("bootstrap"); + coordinator.completeSnapshotRecovery(3); + coordinator.beginReplayRecovery("sequence-gap"); + coordinator.failReplayRecovery(); + + expect(coordinator.getState()).toMatchObject({ + bootstrapped: false, + inFlight: null, + }); + expect(coordinator.beginSnapshotRecovery("replay-failed")).toBe(true); + expect(coordinator.getState().inFlight).toEqual({ + kind: "snapshot", + reason: "replay-failed", + }); + }); +}); diff --git a/apps/web/src/orchestrationRecovery.ts b/apps/web/src/orchestrationRecovery.ts new file mode 100644 index 0000000000..f493a6a789 --- /dev/null +++ b/apps/web/src/orchestrationRecovery.ts @@ -0,0 +1,126 @@ +export type OrchestrationRecoveryReason = "bootstrap" | "sequence-gap" | "replay-failed"; + +export interface OrchestrationRecoveryPhase { + kind: "snapshot" | "replay"; + reason: OrchestrationRecoveryReason; +} + +export interface OrchestrationRecoveryState { + latestSequence: number; + highestObservedSequence: number; + bootstrapped: boolean; + pendingReplay: boolean; + inFlight: OrchestrationRecoveryPhase | null; +} + +type SequencedEvent = Readonly<{ sequence: number }>; + +export function createOrchestrationRecoveryCoordinator() { + let state: OrchestrationRecoveryState = { + latestSequence: 0, + highestObservedSequence: 0, + bootstrapped: false, + pendingReplay: false, + inFlight: null, + }; + + const snapshotState = (): OrchestrationRecoveryState => ({ + ...state, + ...(state.inFlight ? { inFlight: { ...state.inFlight } } : {}), + }); + + const observeSequence = (sequence: number) => { + state.highestObservedSequence = Math.max(state.highestObservedSequence, sequence); + }; + + const shouldReplayAfterRecovery = (): boolean => { + const shouldReplay = + state.pendingReplay || state.highestObservedSequence > state.latestSequence; + state.pendingReplay = false; + return shouldReplay; + }; + + return { + getState(): OrchestrationRecoveryState { + return snapshotState(); + }, + + classifyDomainEvent(sequence: number): "ignore" | "defer" | "recover" | "apply" { + observeSequence(sequence); + if (sequence <= state.latestSequence) { + return "ignore"; + } + if (!state.bootstrapped || state.inFlight) { + state.pendingReplay = true; + return "defer"; + } + if (sequence !== state.latestSequence + 1) { + state.pendingReplay = true; + return "recover"; + } + return "apply"; + }, + + markEventBatchApplied(events: ReadonlyArray): ReadonlyArray { + const nextEvents = events + .filter((event) => event.sequence > state.latestSequence) + .toSorted((left, right) => left.sequence - right.sequence); + if (nextEvents.length === 0) { + return []; + } + + state.latestSequence = nextEvents.at(-1)?.sequence ?? state.latestSequence; + state.highestObservedSequence = Math.max(state.highestObservedSequence, state.latestSequence); + return nextEvents; + }, + + beginSnapshotRecovery(reason: OrchestrationRecoveryReason): boolean { + if (state.inFlight?.kind === "snapshot") { + state.pendingReplay = true; + return false; + } + if (state.inFlight?.kind === "replay") { + state.pendingReplay = true; + return false; + } + state.inFlight = { kind: "snapshot", reason }; + return true; + }, + + completeSnapshotRecovery(snapshotSequence: number): boolean { + state.latestSequence = Math.max(state.latestSequence, snapshotSequence); + state.highestObservedSequence = Math.max(state.highestObservedSequence, state.latestSequence); + state.bootstrapped = true; + state.inFlight = null; + return shouldReplayAfterRecovery(); + }, + + failSnapshotRecovery(): void { + state.inFlight = null; + }, + + beginReplayRecovery(reason: OrchestrationRecoveryReason): boolean { + if (!state.bootstrapped || state.inFlight?.kind === "snapshot") { + state.pendingReplay = true; + return false; + } + if (state.inFlight?.kind === "replay") { + state.pendingReplay = true; + return false; + } + state.pendingReplay = false; + state.inFlight = { kind: "replay", reason }; + return true; + }, + + completeReplayRecovery(): boolean { + state.inFlight = null; + return shouldReplayAfterRecovery(); + }, + + failReplayRecovery(): void { + state.bootstrapped = false; + state.inFlight = null; + }, + }; +} diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 85071953c6..5d61668498 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -32,6 +32,7 @@ import { providerQueryKeys } from "../lib/providerReactQuery"; import { projectQueryKeys } from "../lib/projectReactQuery"; import { collectActiveTerminalThreadIds } from "../lib/terminalStateCleanup"; import { deriveOrchestrationBatchEffects } from "../orchestrationEventEffects"; +import { createOrchestrationRecoveryCoordinator } from "../orchestrationRecovery"; export const Route = createRootRouteWithContext<{ queryClient: QueryClient; @@ -165,12 +166,7 @@ function EventRouter() { const api = readNativeApi(); if (!api) return; let disposed = false; - let latestSequence = 0; - let highestObservedSequence = 0; - let bootstrapped = false; - let snapshotSyncInFlight = false; - let replayInFlight = false; - let pendingReplay = false; + const recovery = createOrchestrationRecoveryCoordinator(); let needsProviderInvalidation = false; const reconcileSnapshotDerivedState = () => { @@ -213,16 +209,11 @@ function EventRouter() { ); const applyEventBatch = (events: Parameters[0]) => { - const nextEvents = events - .filter((event) => event.sequence > latestSequence) - .toSorted((left, right) => left.sequence - right.sequence); + const nextEvents = recovery.markEventBatchApplied(events); if (nextEvents.length === 0) { return; } - latestSequence = nextEvents.at(-1)?.sequence ?? latestSequence; - highestObservedSequence = Math.max(highestObservedSequence, latestSequence); - const batchEffects = deriveOrchestrationBatchEffects(nextEvents); const needsProjectUiSync = nextEvents.some( (event) => @@ -254,83 +245,64 @@ function EventRouter() { } }; - const replayFromLatest = async (): Promise => { - if (!bootstrapped || snapshotSyncInFlight) { - pendingReplay = true; - return; - } - if (replayInFlight) { - pendingReplay = true; + const recoverFromSequenceGap = async (): Promise => { + if (!recovery.beginReplayRecovery("sequence-gap")) { return; } - replayInFlight = true; - pendingReplay = false; try { - const events = await api.orchestration.replayEvents(latestSequence); + const events = await api.orchestration.replayEvents(recovery.getState().latestSequence); if (!disposed) { applyEventBatch(events); } } catch { - bootstrapped = false; - void bootstrapSnapshot(); + recovery.failReplayRecovery(); + void fallbackToSnapshotRecovery(); + return; } - replayInFlight = false; - if ( - !disposed && - bootstrapped && - (pendingReplay || highestObservedSequence > latestSequence) - ) { - void replayFromLatest(); + if (!disposed && recovery.completeReplayRecovery()) { + void recoverFromSequenceGap(); } }; - const bootstrapSnapshot = async (): Promise => { - if (snapshotSyncInFlight) { - pendingReplay = true; + const runSnapshotRecovery = async (reason: "bootstrap" | "replay-failed"): Promise => { + if (!recovery.beginSnapshotRecovery(reason)) { return; } - snapshotSyncInFlight = true; try { const snapshot = await api.orchestration.getSnapshot(); if (!disposed) { - latestSequence = Math.max(latestSequence, snapshot.snapshotSequence); - highestObservedSequence = Math.max(highestObservedSequence, latestSequence); syncServerReadModel(snapshot); - bootstrapped = true; reconcileSnapshotDerivedState(); + if (recovery.completeSnapshotRecovery(snapshot.snapshotSequence)) { + void recoverFromSequenceGap(); + } } } catch { // Keep prior state and wait for welcome or a later replay attempt. + recovery.failSnapshotRecovery(); } + }; - snapshotSyncInFlight = false; - if ( - !disposed && - bootstrapped && - (pendingReplay || highestObservedSequence > latestSequence) - ) { - void replayFromLatest(); - } + const bootstrapFromSnapshot = async (): Promise => { + await runSnapshotRecovery("bootstrap"); + }; + + const fallbackToSnapshotRecovery = async (): Promise => { + await runSnapshotRecovery("replay-failed"); }; const unsubDomainEvent = api.orchestration.onDomainEvent((event) => { - highestObservedSequence = Math.max(highestObservedSequence, event.sequence); - if (event.sequence <= latestSequence) { + const action = recovery.classifyDomainEvent(event.sequence); + if (action === "apply") { + applyEventBatch([event]); return; } - if (!bootstrapped || snapshotSyncInFlight || replayInFlight) { - pendingReplay = true; - return; - } - if (event.sequence !== latestSequence + 1) { - pendingReplay = true; - void replayFromLatest(); - return; + if (action === "recover") { + void recoverFromSequenceGap(); } - applyEventBatch([event]); }); const unsubTerminalEvent = api.terminal.onEvent((event) => { const hasRunningSubprocess = terminalRunningSubprocessFromEvent(event); @@ -349,7 +321,7 @@ function EventRouter() { // Migrate old localStorage settings to server on first connect migrateLocalSettingsToServer(); void (async () => { - await bootstrapSnapshot(); + await bootstrapFromSnapshot(); if (disposed) { return; } From a67bb271d4ac20b1ff6055acca0d9d41472a0d62 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 29 Mar 2026 23:23:09 -0700 Subject: [PATCH 11/22] Bound thread plan catalog cache memory - Replace unbounded Map cache with LRU limits - Estimate per-thread plan entry size before caching --- apps/web/src/components/ChatView.tsx | 39 ++++++++++++++++++++++------ 1 file changed, 31 insertions(+), 8 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 31c12df1f6..280a792650 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -82,13 +82,32 @@ import { type Thread, type TurnDiffSummary, } from "../types"; +import { LRUCache } from "../lib/lruCache"; type ThreadPlanCatalogEntry = Pick; -const threadPlanCatalogCache = new Map< - ThreadId, - { proposedPlans: Thread["proposedPlans"]; entry: ThreadPlanCatalogEntry } ->(); +const MAX_THREAD_PLAN_CATALOG_CACHE_ENTRIES = 500; +const MAX_THREAD_PLAN_CATALOG_CACHE_MEMORY_BYTES = 512 * 1024; +const threadPlanCatalogCache = new LRUCache<{ + proposedPlans: Thread["proposedPlans"]; + entry: ThreadPlanCatalogEntry; +}>(MAX_THREAD_PLAN_CATALOG_CACHE_ENTRIES, MAX_THREAD_PLAN_CATALOG_CACHE_MEMORY_BYTES); + +function estimateThreadPlanCatalogEntrySize(thread: Thread): number { + return Math.max( + 64, + thread.id.length + + thread.proposedPlans.reduce( + (total, plan) => + total + + plan.id.length + + plan.planMarkdown.length + + plan.updatedAt.length + + (plan.turnId?.length ?? 0), + 0, + ), + ); +} function toThreadPlanCatalogEntry(thread: Thread): ThreadPlanCatalogEntry { const cached = threadPlanCatalogCache.get(thread.id); @@ -100,10 +119,14 @@ function toThreadPlanCatalogEntry(thread: Thread): ThreadPlanCatalogEntry { id: thread.id, proposedPlans: thread.proposedPlans, }; - threadPlanCatalogCache.set(thread.id, { - proposedPlans: thread.proposedPlans, - entry, - }); + threadPlanCatalogCache.set( + thread.id, + { + proposedPlans: thread.proposedPlans, + entry, + }, + estimateThreadPlanCatalogEntrySize(thread), + ); return entry; } import { basenameOfPath } from "../vscode-icons"; From 314edce50d17f0d0f526f1b374a693a80bdcc0fd Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 29 Mar 2026 23:33:44 -0700 Subject: [PATCH 12/22] Wait for server thread startup before opening plan sidebar - Add thread-start detection helper and wait logic - Cover immediate, subscription-driven, and timeout cases --- .../web/src/components/ChatView.logic.test.ts | 116 +++++++++++++++++- apps/web/src/components/ChatView.logic.ts | 42 +++++++ apps/web/src/components/ChatView.tsx | 12 +- 3 files changed, 163 insertions(+), 7 deletions(-) diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index cf77c44eca..e06e4dbc95 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -1,11 +1,13 @@ import { ProjectId, ThreadId, TurnId } from "@t3tools/contracts"; -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { useStore } from "../store"; import { buildExpiredTerminalContextToastCopy, createLocalDispatchSnapshot, deriveComposerSendState, hasServerAcknowledgedLocalDispatch, + waitForStartedServerThread, } from "./ChatView.logic"; describe("deriveComposerSendState", () => { @@ -73,6 +75,118 @@ describe("buildExpiredTerminalContextToastCopy", () => { }); }); +const makeThread = (input?: { + id?: ThreadId; + latestTurn?: { + turnId: TurnId; + state: "running" | "completed"; + requestedAt: string; + startedAt: string | null; + completedAt: string | null; + } | null; +}) => ({ + id: input?.id ?? ThreadId.makeUnsafe("thread-1"), + codexThreadId: null, + projectId: ProjectId.makeUnsafe("project-1"), + title: "Thread", + modelSelection: { provider: "codex" as const, model: "gpt-5.4" }, + runtimeMode: "full-access" as const, + interactionMode: "default" as const, + session: null, + messages: [], + proposedPlans: [], + error: null, + createdAt: "2026-03-29T00:00:00.000Z", + archivedAt: null, + updatedAt: "2026-03-29T00:00:00.000Z", + latestTurn: input?.latestTurn + ? { + ...input.latestTurn, + assistantMessageId: null, + } + : null, + branch: null, + worktreePath: null, + turnDiffSummaries: [], + activities: [], +}); + +afterEach(() => { + vi.useRealTimers(); + useStore.setState((state) => ({ + ...state, + projects: [], + threads: [], + bootstrapComplete: true, + })); +}); + +describe("waitForStartedServerThread", () => { + it("resolves immediately when the thread is already started", async () => { + const threadId = ThreadId.makeUnsafe("thread-started"); + useStore.setState((state) => ({ + ...state, + threads: [ + makeThread({ + id: threadId, + latestTurn: { + turnId: TurnId.makeUnsafe("turn-started"), + state: "running", + requestedAt: "2026-03-29T00:00:01.000Z", + startedAt: "2026-03-29T00:00:01.000Z", + completedAt: null, + }, + }), + ], + })); + + await expect(waitForStartedServerThread(threadId)).resolves.toBe(true); + }); + + it("waits for the thread to start via subscription updates", async () => { + const threadId = ThreadId.makeUnsafe("thread-wait"); + useStore.setState((state) => ({ + ...state, + threads: [makeThread({ id: threadId })], + })); + + const promise = waitForStartedServerThread(threadId, 500); + + useStore.setState((state) => ({ + ...state, + threads: [ + makeThread({ + id: threadId, + latestTurn: { + turnId: TurnId.makeUnsafe("turn-started"), + state: "running", + requestedAt: "2026-03-29T00:00:01.000Z", + startedAt: "2026-03-29T00:00:01.000Z", + completedAt: null, + }, + }), + ], + })); + + await expect(promise).resolves.toBe(true); + }); + + it("returns false after the timeout when the thread never starts", async () => { + vi.useFakeTimers(); + + const threadId = ThreadId.makeUnsafe("thread-timeout"); + useStore.setState((state) => ({ + ...state, + threads: [makeThread({ id: threadId })], + })); + const promise = waitForStartedServerThread(threadId, 500); + + await vi.advanceTimersByTimeAsync(500); + + await expect(promise).resolves.toBe(false); + }); +}); + describe("hasServerAcknowledgedLocalDispatch", () => { const projectId = ProjectId.makeUnsafe("project-1"); const previousLatestTurn = { diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index a8d339d9d5..cc458f2bc8 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -3,6 +3,7 @@ import { type ChatMessage, type SessionPhase, type Thread, type ThreadSession } import { randomUUID } from "~/lib/utils"; import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore"; import { Schema } from "effect"; +import { useStore } from "../store"; import { filterTerminalContextsWithText, stripInlineTerminalContextPlaceholders, @@ -159,6 +160,47 @@ export function buildExpiredTerminalContextToastCopy( }; } +export function threadHasStarted(thread: Thread | null | undefined): boolean { + return Boolean( + thread && (thread.latestTurn !== null || thread.messages.length > 0 || thread.session !== null), + ); +} + +export async function waitForStartedServerThread( + threadId: ThreadId, + timeoutMs = 1_000, +): Promise { + const thread = useStore.getState().threads.find((thread) => thread.id === threadId); + + if (threadHasStarted(thread)) { + return true; + } + + return await new Promise((resolve) => { + let settled = false; + const finish = (result: boolean) => { + if (settled) { + return; + } + settled = true; + globalThis.clearTimeout(timeoutId); + unsubscribe(); + resolve(result); + }; + + const timeoutId = globalThis.setTimeout(() => { + finish(false); + }, timeoutMs); + + const unsubscribe = useStore.subscribe((state) => { + if (!threadHasStarted(state.threads.find((thread) => thread.id === threadId))) { + return; + } + finish(true); + }); + }); +} + export interface LocalDispatchSnapshot { startedAt: string; preparingWorktree: boolean; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 280a792650..9285f82dc5 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -229,6 +229,8 @@ import { readFileAsDataUrl, revokeBlobPreviewUrl, revokeUserMessagePreviewUrls, + threadHasStarted, + waitForStartedServerThread, } from "./ChatView.logic"; import { useLocalStorage } from "~/hooks/useLocalStorage"; @@ -737,12 +739,7 @@ export default function ChatView({ threadId }: ChatViewProps) { const selectedProviderByThreadId = composerDraft.activeProvider ?? null; const threadProvider = activeThread?.modelSelection.provider ?? activeProject?.defaultModelSelection?.provider ?? null; - const hasThreadStarted = Boolean( - activeThread && - (activeThread.latestTurn !== null || - activeThread.messages.length > 0 || - activeThread.session !== null), - ); + const hasThreadStarted = threadHasStarted(activeThread); const lockedProvider: ProviderKind | null = hasThreadStarted ? (sessionProvider ?? threadProvider ?? selectedProviderByThreadId ?? null) : null; @@ -3233,6 +3230,9 @@ export default function ChatView({ threadId }: ChatViewProps) { createdAt, }); }) + .then(() => { + return waitForStartedServerThread(nextThreadId); + }) .then(() => { // Signal that the plan sidebar should open on the new thread. planSidebarOpenOnNextThreadRef.current = true; From 0bd63bfb805cd279a1f06ea87506bba6f87be641 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 29 Mar 2026 23:47:55 -0700 Subject: [PATCH 13/22] Refine sidebar ordering and thread lookups - Share preferred-id ordering across sidebar and new-thread flows - Add resilient thread startup wait logic and selector hooks - Cover the new ordering helper and race condition in tests --- .../web/src/components/ChatView.logic.test.ts | 35 +++++++++++++++++++ apps/web/src/components/ChatView.logic.ts | 21 +++++++---- apps/web/src/components/ChatView.tsx | 9 ++--- apps/web/src/components/Sidebar.logic.test.ts | 25 +++++++++++++ apps/web/src/components/Sidebar.logic.ts | 20 +++++++++++ apps/web/src/components/Sidebar.tsx | 14 +++----- apps/web/src/hooks/useHandleNewThread.ts | 18 +++++----- apps/web/src/storeSelectors.ts | 14 ++++++++ 8 files changed, 128 insertions(+), 28 deletions(-) create mode 100644 apps/web/src/storeSelectors.ts diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index e06e4dbc95..80c842d91f 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -113,6 +113,7 @@ const makeThread = (input?: { afterEach(() => { vi.useRealTimers(); + vi.restoreAllMocks(); useStore.setState((state) => ({ ...state, projects: [], @@ -171,6 +172,40 @@ describe("waitForStartedServerThread", () => { await expect(promise).resolves.toBe(true); }); + it("handles the thread starting between the initial read and subscription setup", async () => { + const threadId = ThreadId.makeUnsafe("thread-race"); + useStore.setState((state) => ({ + ...state, + threads: [makeThread({ id: threadId })], + })); + + const originalSubscribe = useStore.subscribe.bind(useStore); + let raced = false; + vi.spyOn(useStore, "subscribe").mockImplementation((listener) => { + if (!raced) { + raced = true; + useStore.setState((state) => ({ + ...state, + threads: [ + makeThread({ + id: threadId, + latestTurn: { + turnId: TurnId.makeUnsafe("turn-race"), + state: "running", + requestedAt: "2026-03-29T00:00:01.000Z", + startedAt: "2026-03-29T00:00:01.000Z", + completedAt: null, + }, + }), + ], + })); + } + return originalSubscribe(listener); + }); + + await expect(waitForStartedServerThread(threadId, 500)).resolves.toBe(true); + }); + it("returns false after the timeout when the thread never starts", async () => { vi.useFakeTimers(); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index cc458f2bc8..1821c65ed9 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -170,7 +170,8 @@ export async function waitForStartedServerThread( threadId: ThreadId, timeoutMs = 1_000, ): Promise { - const thread = useStore.getState().threads.find((thread) => thread.id === threadId); + const getThread = () => useStore.getState().threads.find((thread) => thread.id === threadId); + const thread = getThread(); if (threadHasStarted(thread)) { return true; @@ -178,26 +179,34 @@ export async function waitForStartedServerThread( return await new Promise((resolve) => { let settled = false; + let timeoutId: ReturnType | null = null; const finish = (result: boolean) => { if (settled) { return; } settled = true; - globalThis.clearTimeout(timeoutId); + if (timeoutId !== null) { + globalThis.clearTimeout(timeoutId); + } unsubscribe(); resolve(result); }; - const timeoutId = globalThis.setTimeout(() => { - finish(false); - }, timeoutMs); - const unsubscribe = useStore.subscribe((state) => { if (!threadHasStarted(state.threads.find((thread) => thread.id === threadId))) { return; } finish(true); }); + + if (threadHasStarted(getThread())) { + finish(true); + return; + } + + timeoutId = globalThis.setTimeout(() => { + finish(false); + }, timeoutMs); }); } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 9285f82dc5..725ebd95ae 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -64,7 +64,8 @@ import { setPendingUserInputCustomAnswer, type PendingUserInputDraftAnswer, } from "../pendingUserInput"; -import { selectProjectById, selectThreadById, useStore } from "../store"; +import { useStore } from "../store"; +import { useProjectById, useThreadById } from "../storeSelectors"; import { useUiStateStore } from "../uiStateStore"; import { buildPlanImplementationThreadTitle, @@ -368,7 +369,7 @@ function useLocalDispatchState(input: { } export default function ChatView({ threadId }: ChatViewProps) { - const serverThread = useStore(selectThreadById(threadId)); + const serverThread = useThreadById(threadId); const setStoreThreadError = useStore((store) => store.setError); const setStoreThreadBranch = useStore((store) => store.setThreadBranch); const markThreadVisited = useUiStateStore((store) => store.markThreadVisited); @@ -587,7 +588,7 @@ export default function ChatView({ threadId }: ChatViewProps) { [composerTerminalContexts, removeComposerDraftTerminalContext, setPrompt, threadId], ); - const fallbackDraftProject = useStore(selectProjectById(draftThread?.projectId)); + const fallbackDraftProject = useProjectById(draftThread?.projectId); const threadPlanCatalog = useStore( useShallow((store) => store.threads.map(toThreadPlanCatalogEntry)), ); @@ -623,7 +624,7 @@ export default function ChatView({ threadId }: ChatViewProps) { [activeThread?.activities], ); const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null); - const activeProject = useStore(selectProjectById(activeThread?.projectId)); + const activeProject = useProjectById(activeThread?.projectId); const openPullRequestDialog = useCallback( (reference?: string) => { diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 544bd48a5d..40da9308ed 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -8,6 +8,7 @@ import { getProjectSortTimestamp, hasUnseenCompletion, isContextMenuPointerDown, + orderItemsByPreferredIds, resolveProjectStatusIndicator, resolveSidebarNewThreadEnvMode, resolveThreadRowClassName, @@ -99,6 +100,30 @@ describe("resolveSidebarNewThreadEnvMode", () => { }); }); +describe("orderItemsByPreferredIds", () => { + it("keeps preferred ids first, skips stale ids, and preserves the relative order of remaining items", () => { + const ordered = orderItemsByPreferredIds({ + items: [ + { id: ProjectId.makeUnsafe("project-1"), name: "One" }, + { id: ProjectId.makeUnsafe("project-2"), name: "Two" }, + { id: ProjectId.makeUnsafe("project-3"), name: "Three" }, + ], + preferredIds: [ + ProjectId.makeUnsafe("project-3"), + ProjectId.makeUnsafe("project-missing"), + ProjectId.makeUnsafe("project-1"), + ], + getId: (project) => project.id, + }); + + expect(ordered.map((project) => project.id)).toEqual([ + ProjectId.makeUnsafe("project-3"), + ProjectId.makeUnsafe("project-1"), + ProjectId.makeUnsafe("project-2"), + ]); + }); +}); + describe("resolveAdjacentThreadId", () => { it("resolves adjacent thread ids in ordered sidebar traversal", () => { const threads = [ diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 759c363252..0683295994 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -74,6 +74,26 @@ export function resolveSidebarNewThreadEnvMode(input: { return input.requestedEnvMode ?? input.defaultEnvMode; } +export function orderItemsByPreferredIds(input: { + items: readonly TItem[]; + preferredIds: readonly TId[]; + getId: (item: TItem) => TId; +}): TItem[] { + const { getId, items, preferredIds } = input; + if (preferredIds.length === 0) { + return [...items]; + } + + const itemsById = new Map(items.map((item) => [getId(item), item] as const)); + const preferredIdSet = new Set(preferredIds); + const ordered = preferredIds.flatMap((id) => { + const item = itemsById.get(id); + return item ? [item] : []; + }); + const remaining = items.filter((item) => !preferredIdSet.has(getId(item))); + return [...ordered, ...remaining]; +} + export function getVisibleSidebarThreadIds( renderedProjects: readonly { renderedThreads: readonly { diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index b450493345..acb70afe10 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -115,6 +115,7 @@ import { resolveSidebarNewThreadEnvMode, resolveThreadRowClassName, resolveThreadStatusPill, + orderItemsByPreferredIds, shouldClearThreadSelectionOnMouseDown, sortProjectsForSidebar, sortThreadsForSidebar, @@ -501,16 +502,11 @@ export default function Sidebar() { const shouldBrowseForProjectImmediately = isElectron && !isLinuxDesktop; const shouldShowProjectPathEntry = addingProject && !shouldBrowseForProjectImmediately; const orderedProjects = useMemo(() => { - if (projectOrder.length === 0) { - return projects; - } - const projectsById = new Map(projects.map((project) => [project.id, project] as const)); - const ordered = projectOrder.flatMap((projectId) => { - const project = projectsById.get(projectId); - return project ? [project] : []; + return orderItemsByPreferredIds({ + items: projects, + preferredIds: projectOrder, + getId: (project) => project.id, }); - const remaining = projects.filter((project) => !projectOrder.includes(project.id)); - return [...ordered, ...remaining]; }, [projectOrder, projects]); const sidebarProjects = useMemo( () => diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index e0ad9553b4..1547035bf4 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -8,7 +8,9 @@ import { useComposerDraftStore, } from "../composerDraftStore"; import { newThreadId } from "../lib/utils"; -import { selectThreadById, useStore } from "../store"; +import { orderItemsByPreferredIds } from "../components/Sidebar.logic"; +import { useStore } from "../store"; +import { useThreadById } from "../storeSelectors"; import { useUiStateStore } from "../uiStateStore"; export function useHandleNewThread() { @@ -19,18 +21,16 @@ export function useHandleNewThread() { strict: false, select: (params) => (params.threadId ? ThreadId.makeUnsafe(params.threadId) : null), }); - const activeThread = useStore(selectThreadById(routeThreadId)); + const activeThread = useThreadById(routeThreadId); const activeDraftThread = useComposerDraftStore((store) => routeThreadId ? (store.draftThreadsByThreadId[routeThreadId] ?? null) : null, ); const orderedProjects = useMemo(() => { - if (projectOrder.length === 0) { - return projectIds; - } - const projectIdsSet = new Set(projectIds); - const ordered = projectOrder.filter((projectId) => projectIdsSet.has(projectId)); - const remaining = projectIds.filter((projectId) => !projectOrder.includes(projectId)); - return [...ordered, ...remaining]; + return orderItemsByPreferredIds({ + items: projectIds, + preferredIds: projectOrder, + getId: (projectId) => projectId, + }); }, [projectIds, projectOrder]); const handleNewThread = useCallback( diff --git a/apps/web/src/storeSelectors.ts b/apps/web/src/storeSelectors.ts new file mode 100644 index 0000000000..271fbb256b --- /dev/null +++ b/apps/web/src/storeSelectors.ts @@ -0,0 +1,14 @@ +import { type ThreadId } from "@t3tools/contracts"; +import { useMemo } from "react"; +import { selectProjectById, selectThreadById, useStore } from "./store"; +import { type Project, type Thread } from "./types"; + +export function useProjectById(projectId: Project["id"] | null | undefined): Project | undefined { + const selector = useMemo(() => selectProjectById(projectId), [projectId]); + return useStore(selector); +} + +export function useThreadById(threadId: ThreadId | null | undefined): Thread | undefined { + const selector = useMemo(() => selectThreadById(threadId), [threadId]); + return useStore(selector); +} From 3bbafdb07c07988712d217799e59aec0b787acc7 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 30 Mar 2026 00:00:57 -0700 Subject: [PATCH 14/22] Deduplicate repeated preferred sidebar items - Skip duplicate preferred IDs when ordering sidebar items - Add regression test for repeated preferred IDs --- apps/web/src/components/Sidebar.logic.test.ts | 20 +++++++++++++++++++ apps/web/src/components/Sidebar.logic.ts | 10 +++++++++- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 40da9308ed..03b0378542 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -122,6 +122,26 @@ describe("orderItemsByPreferredIds", () => { ProjectId.makeUnsafe("project-2"), ]); }); + + it("does not duplicate items when preferred ids repeat", () => { + const ordered = orderItemsByPreferredIds({ + items: [ + { id: ProjectId.makeUnsafe("project-1"), name: "One" }, + { id: ProjectId.makeUnsafe("project-2"), name: "Two" }, + ], + preferredIds: [ + ProjectId.makeUnsafe("project-2"), + ProjectId.makeUnsafe("project-1"), + ProjectId.makeUnsafe("project-2"), + ], + getId: (project) => project.id, + }); + + expect(ordered.map((project) => project.id)).toEqual([ + ProjectId.makeUnsafe("project-2"), + ProjectId.makeUnsafe("project-1"), + ]); + }); }); describe("resolveAdjacentThreadId", () => { diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 0683295994..b4c09a65c9 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -86,9 +86,17 @@ export function orderItemsByPreferredIds(input: { const itemsById = new Map(items.map((item) => [getId(item), item] as const)); const preferredIdSet = new Set(preferredIds); + const emittedPreferredIds = new Set(); const ordered = preferredIds.flatMap((id) => { + if (emittedPreferredIds.has(id)) { + return []; + } const item = itemsById.get(id); - return item ? [item] : []; + if (!item) { + return []; + } + emittedPreferredIds.add(id); + return [item]; }); const remaining = items.filter((item) => !preferredIdSet.has(getId(item))); return [...ordered, ...remaining]; From 22a0b5d9d884f4a6b418fce10bdf96eacab404e2 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 30 Mar 2026 00:09:27 -0700 Subject: [PATCH 15/22] Use shared contract types in web code - Replace inferred local types with explicit shared contract exports - Keep router, chat, and sidebar tests aligned with contract shapes --- apps/web/src/components/ChatView.tsx | 3 ++- apps/web/src/components/Sidebar.logic.test.ts | 4 ++-- apps/web/src/router.ts | 4 +--- apps/web/src/routes/__root.tsx | 4 ++-- 4 files changed, 7 insertions(+), 8 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 725ebd95ae..27f9b05634 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -20,6 +20,7 @@ import { OrchestrationThreadActivity, ProviderInteractionMode, RuntimeMode, + TerminalOpenInput, } from "@t3tools/contracts"; import { applyClaudePromptEffortPrefix, normalizeModelSlug } from "@t3tools/shared/model"; import { truncate } from "@t3tools/shared/String"; @@ -1542,7 +1543,7 @@ export default function ChatView({ threadId }: ChatViewProps) { worktreePath: options?.worktreePath ?? activeThread.worktreePath ?? null, ...(options?.env ? { extraEnv: options.env } : {}), }); - const openTerminalInput: Parameters[0] = shouldCreateNewTerminal + const openTerminalInput: TerminalOpenInput = shouldCreateNewTerminal ? { threadId: activeThreadId, terminalId: targetTerminalId, diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 03b0378542..6b52da1922 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -17,7 +17,7 @@ import { sortProjectsForSidebar, sortThreadsForSidebar, } from "./Sidebar.logic"; -import { ProjectId, ThreadId } from "@t3tools/contracts"; +import { OrchestrationLatestTurn, ProjectId, ThreadId } from "@t3tools/contracts"; import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, @@ -28,7 +28,7 @@ import { function makeLatestTurn(overrides?: { completedAt?: string | null; startedAt?: string | null; -}): Parameters[0]["latestTurn"] { +}): OrchestrationLatestTurn { return { turnId: "turn-1" as never, state: "completed", diff --git a/apps/web/src/router.ts b/apps/web/src/router.ts index 0cc711522f..16b78e69dc 100644 --- a/apps/web/src/router.ts +++ b/apps/web/src/router.ts @@ -1,11 +1,9 @@ import { createElement } from "react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { createRouter } from "@tanstack/react-router"; +import { createRouter, RouterHistory } from "@tanstack/react-router"; import { routeTree } from "./routeTree.gen"; -type RouterHistory = NonNullable[0]["history"]>; - export function getRouter(history: RouterHistory) { const queryClient = new QueryClient(); diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 5d61668498..486a3560e9 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -1,4 +1,4 @@ -import { ThreadId } from "@t3tools/contracts"; +import { OrchestrationEvent, ThreadId } from "@t3tools/contracts"; import { Outlet, createRootRouteWithContext, @@ -208,7 +208,7 @@ function EventRouter() { }, ); - const applyEventBatch = (events: Parameters[0]) => { + const applyEventBatch = (events: ReadonlyArray) => { const nextEvents = recovery.markEventBatchApplied(events); if (nextEvents.length === 0) { return; From 9084cf5cb2f9484c00fd9ebf7205300d18b13edd Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 30 Mar 2026 00:10:16 -0700 Subject: [PATCH 16/22] Move thread plan catalog cache helper below constants - Reorder ChatView helper declarations for readability - No functional change --- apps/web/src/components/ChatView.tsx | 91 ++++++++++++++-------------- 1 file changed, 46 insertions(+), 45 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 27f9b05634..17af30646c 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -86,51 +86,6 @@ import { } from "../types"; import { LRUCache } from "../lib/lruCache"; -type ThreadPlanCatalogEntry = Pick; - -const MAX_THREAD_PLAN_CATALOG_CACHE_ENTRIES = 500; -const MAX_THREAD_PLAN_CATALOG_CACHE_MEMORY_BYTES = 512 * 1024; -const threadPlanCatalogCache = new LRUCache<{ - proposedPlans: Thread["proposedPlans"]; - entry: ThreadPlanCatalogEntry; -}>(MAX_THREAD_PLAN_CATALOG_CACHE_ENTRIES, MAX_THREAD_PLAN_CATALOG_CACHE_MEMORY_BYTES); - -function estimateThreadPlanCatalogEntrySize(thread: Thread): number { - return Math.max( - 64, - thread.id.length + - thread.proposedPlans.reduce( - (total, plan) => - total + - plan.id.length + - plan.planMarkdown.length + - plan.updatedAt.length + - (plan.turnId?.length ?? 0), - 0, - ), - ); -} - -function toThreadPlanCatalogEntry(thread: Thread): ThreadPlanCatalogEntry { - const cached = threadPlanCatalogCache.get(thread.id); - if (cached && cached.proposedPlans === thread.proposedPlans) { - return cached.entry; - } - - const entry: ThreadPlanCatalogEntry = { - id: thread.id, - proposedPlans: thread.proposedPlans, - }; - threadPlanCatalogCache.set( - thread.id, - { - proposedPlans: thread.proposedPlans, - entry, - }, - estimateThreadPlanCatalogEntrySize(thread), - ); - return entry; -} import { basenameOfPath } from "../vscode-icons"; import { useTheme } from "../hooks/useTheme"; import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; @@ -247,6 +202,52 @@ const EMPTY_AVAILABLE_EDITORS: EditorId[] = []; const EMPTY_PROVIDERS: ServerProvider[] = []; const EMPTY_PENDING_USER_INPUT_ANSWERS: Record = {}; +type ThreadPlanCatalogEntry = Pick; + +const MAX_THREAD_PLAN_CATALOG_CACHE_ENTRIES = 500; +const MAX_THREAD_PLAN_CATALOG_CACHE_MEMORY_BYTES = 512 * 1024; +const threadPlanCatalogCache = new LRUCache<{ + proposedPlans: Thread["proposedPlans"]; + entry: ThreadPlanCatalogEntry; +}>(MAX_THREAD_PLAN_CATALOG_CACHE_ENTRIES, MAX_THREAD_PLAN_CATALOG_CACHE_MEMORY_BYTES); + +function estimateThreadPlanCatalogEntrySize(thread: Thread): number { + return Math.max( + 64, + thread.id.length + + thread.proposedPlans.reduce( + (total, plan) => + total + + plan.id.length + + plan.planMarkdown.length + + plan.updatedAt.length + + (plan.turnId?.length ?? 0), + 0, + ), + ); +} + +function toThreadPlanCatalogEntry(thread: Thread): ThreadPlanCatalogEntry { + const cached = threadPlanCatalogCache.get(thread.id); + if (cached && cached.proposedPlans === thread.proposedPlans) { + return cached.entry; + } + + const entry: ThreadPlanCatalogEntry = { + id: thread.id, + proposedPlans: thread.proposedPlans, + }; + threadPlanCatalogCache.set( + thread.id, + { + proposedPlans: thread.proposedPlans, + entry, + }, + estimateThreadPlanCatalogEntrySize(thread), + ); + return entry; +} + function formatOutgoingPrompt(params: { provider: ProviderKind; model: string | null; From 0e1dfe5999d2e1ce43fc373821153ea99ea002b1 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 30 Mar 2026 00:39:03 -0700 Subject: [PATCH 17/22] Reduce chat thread catalog churn and guard replay recovery - Scope thread plan catalog selection to active/source threads - Avoid immediate replay retries when recovery makes no progress --- apps/web/src/components/ChatView.tsx | 46 ++++++++++++++++++++-- apps/web/src/orchestrationRecovery.test.ts | 17 ++++++++ apps/web/src/orchestrationRecovery.ts | 10 +++++ 3 files changed, 69 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 17af30646c..4e402ad58e 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -28,7 +28,6 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } fr import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useDebouncedValue } from "@tanstack/react-pacer"; import { useNavigate, useSearch } from "@tanstack/react-router"; -import { useShallow } from "zustand/react/shallow"; import { gitBranchesQueryOptions, gitCreateWorktreeMutationOptions } from "~/lib/gitReactQuery"; import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery"; import { serverConfigQueryOptions, serverQueryKeys } from "~/lib/serverReactQuery"; @@ -248,6 +247,35 @@ function toThreadPlanCatalogEntry(thread: Thread): ThreadPlanCatalogEntry { return entry; } +function useThreadPlanCatalog(threadIds: readonly ThreadId[]): ThreadPlanCatalogEntry[] { + const selector = useMemo(() => { + let previousThreads: Array | null = null; + let previousEntries: ThreadPlanCatalogEntry[] = []; + + return (state: { threads: Thread[] }): ThreadPlanCatalogEntry[] => { + const nextThreads = threadIds.map((threadId) => + state.threads.find((thread) => thread.id === threadId), + ); + const cachedThreads = previousThreads; + if ( + cachedThreads && + nextThreads.length === cachedThreads.length && + nextThreads.every((thread, index) => thread === cachedThreads[index]) + ) { + return previousEntries; + } + + previousThreads = nextThreads; + previousEntries = nextThreads.flatMap((thread) => + thread ? [toThreadPlanCatalogEntry(thread)] : [], + ); + return previousEntries; + }; + }, [threadIds]); + + return useStore(selector); +} + function formatOutgoingPrompt(params: { provider: ProviderKind; model: string | null; @@ -591,9 +619,6 @@ export default function ChatView({ threadId }: ChatViewProps) { ); const fallbackDraftProject = useProjectById(draftThread?.projectId); - const threadPlanCatalog = useStore( - useShallow((store) => store.threads.map(toThreadPlanCatalogEntry)), - ); const localDraftError = serverThread ? null : (localDraftErrorsByThreadId[threadId] ?? null); const localDraftThread = useMemo( () => @@ -621,6 +646,19 @@ export default function ChatView({ threadId }: ChatViewProps) { const diffOpen = rawSearch.diff === "1"; const activeThreadId = activeThread?.id ?? null; const activeLatestTurn = activeThread?.latestTurn ?? null; + const threadPlanCatalog = useThreadPlanCatalog( + useMemo(() => { + const threadIds: ThreadId[] = []; + if (activeThread?.id) { + threadIds.push(activeThread.id); + } + const sourceThreadId = activeLatestTurn?.sourceProposedPlan?.threadId; + if (sourceThreadId && sourceThreadId !== activeThread?.id) { + threadIds.push(sourceThreadId); + } + return threadIds; + }, [activeLatestTurn?.sourceProposedPlan?.threadId, activeThread?.id]), + ); const activeContextWindow = useMemo( () => deriveLatestContextWindowSnapshot(activeThread?.activities ?? []), [activeThread?.activities], diff --git a/apps/web/src/orchestrationRecovery.test.ts b/apps/web/src/orchestrationRecovery.test.ts index 07b782f9ae..bea16cdbce 100644 --- a/apps/web/src/orchestrationRecovery.test.ts +++ b/apps/web/src/orchestrationRecovery.test.ts @@ -62,6 +62,23 @@ describe("createOrchestrationRecoveryCoordinator", () => { expect(coordinator.completeReplayRecovery()).toBe(true); }); + it("does not immediately replay again when replay returns no new events", () => { + const coordinator = createOrchestrationRecoveryCoordinator(); + + coordinator.beginSnapshotRecovery("bootstrap"); + coordinator.completeSnapshotRecovery(3); + coordinator.classifyDomainEvent(5); + coordinator.beginReplayRecovery("sequence-gap"); + + expect(coordinator.completeReplayRecovery()).toBe(false); + expect(coordinator.getState()).toMatchObject({ + latestSequence: 3, + highestObservedSequence: 5, + pendingReplay: false, + inFlight: null, + }); + }); + it("marks replay failure as unbootstrapped so snapshot fallback is recovery-only", () => { const coordinator = createOrchestrationRecoveryCoordinator(); diff --git a/apps/web/src/orchestrationRecovery.ts b/apps/web/src/orchestrationRecovery.ts index f493a6a789..ee81d5d539 100644 --- a/apps/web/src/orchestrationRecovery.ts +++ b/apps/web/src/orchestrationRecovery.ts @@ -23,6 +23,7 @@ export function createOrchestrationRecoveryCoordinator() { pendingReplay: false, inFlight: null, }; + let replayStartSequence: number | null = null; const snapshotState = (): OrchestrationRecoveryState => ({ ...state, @@ -109,16 +110,25 @@ export function createOrchestrationRecoveryCoordinator() { return false; } state.pendingReplay = false; + replayStartSequence = state.latestSequence; state.inFlight = { kind: "replay", reason }; return true; }, completeReplayRecovery(): boolean { + const replayMadeProgress = + replayStartSequence !== null && state.latestSequence > replayStartSequence; + replayStartSequence = null; state.inFlight = null; + if (!replayMadeProgress) { + state.pendingReplay = false; + return false; + } return shouldReplayAfterRecovery(); }, failReplayRecovery(): void { + replayStartSequence = null; state.bootstrapped = false; state.inFlight = null; }, From ea6a5ae7737129fb4b3bdd66b138717a92eae853 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 30 Mar 2026 07:59:06 +0000 Subject: [PATCH 18/22] fix: add latestTurn regression guard to thread.message-sent handler Add the same defensive guard that thread.turn-diff-completed uses to prevent a late-arriving assistant message from an older turn from overwriting latestTurn, which could regress the current turn's progress. Applied via @cursor push command --- apps/web/src/store.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index 40c80b12c7..a44447a482 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -608,7 +608,9 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve : [...thread.messages, message]; const cappedMessages = messages.slice(-MAX_THREAD_MESSAGES); const latestTurn: Thread["latestTurn"] = - event.payload.role === "assistant" && event.payload.turnId !== null + event.payload.role === "assistant" && + event.payload.turnId !== null && + (thread.latestTurn === null || thread.latestTurn.turnId === event.payload.turnId) ? buildLatestTurn({ previous: thread.latestTurn, turnId: event.payload.turnId, From dea02d4cd327dcb4731862af8e3c935fc9cca9ee Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 30 Mar 2026 16:59:01 +0000 Subject: [PATCH 19/22] fix: preserve sourceProposedPlan through incremental event path The thread.turn-start-requested handler updated modelSelection, runtimeMode, and interactionMode but never stored the event's sourceProposedPlan field. Because buildLatestTurn only carries forward sourceProposedPlan from the previous latestTurn when turnIds match, the field was permanently lost for new turns created via the incremental event path. Fix: - Add pendingSourceProposedPlan field to Thread to bridge the gap between turn-start-requested (which has the plan reference but no turnId) and later events that call buildLatestTurn (which create the turn). - Store event.payload.sourceProposedPlan in the turn-start-requested handler. - Pass pendingSourceProposedPlan to buildLatestTurn as a fallback for new turns where the previous latestTurn turnId doesn't match. - Seed pendingSourceProposedPlan in mapThread (snapshot path) for consistency. Applied via @cursor push command --- apps/web/src/store.ts | 14 +++++++++++--- apps/web/src/types.ts | 1 + 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index a44447a482..990a9e5b14 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -158,6 +158,7 @@ function mapThread(thread: OrchestrationThread): Thread { archivedAt: thread.archivedAt, updatedAt: thread.updatedAt, latestTurn: thread.latestTurn, + pendingSourceProposedPlan: thread.latestTurn?.sourceProposedPlan, branch: thread.branch, worktreePath: thread.worktreePath, turnDiffSummaries: thread.checkpoints.map(mapTurnDiffSummary), @@ -214,7 +215,12 @@ function buildLatestTurn(params: { startedAt: string | null; completedAt: string | null; assistantMessageId: NonNullable["assistantMessageId"]; + sourceProposedPlan?: Thread["pendingSourceProposedPlan"]; }): NonNullable { + const resolvedPlan = + params.previous?.turnId === params.turnId + ? params.previous.sourceProposedPlan + : params.sourceProposedPlan; return { turnId: params.turnId, state: params.state, @@ -222,9 +228,7 @@ function buildLatestTurn(params: { startedAt: params.startedAt, completedAt: params.completedAt, assistantMessageId: params.assistantMessageId, - ...(params.previous?.turnId === params.turnId && params.previous.sourceProposedPlan - ? { sourceProposedPlan: params.previous.sourceProposedPlan } - : {}), + ...(resolvedPlan ? { sourceProposedPlan: resolvedPlan } : {}), }; } @@ -534,6 +538,7 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve : {}), runtimeMode: event.payload.runtimeMode, interactionMode: event.payload.interactionMode, + pendingSourceProposedPlan: event.payload.sourceProposedPlan, updatedAt: event.occurredAt, })); return threads === state.threads ? state : { ...state, threads }; @@ -629,6 +634,7 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve thread.latestTurn?.turnId === event.payload.turnId ? (thread.latestTurn.startedAt ?? event.payload.createdAt) : event.payload.createdAt, + sourceProposedPlan: thread.pendingSourceProposedPlan, completedAt: event.payload.streaming ? thread.latestTurn?.turnId === event.payload.turnId ? (thread.latestTurn.completedAt ?? null) @@ -671,6 +677,7 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve thread.latestTurn?.turnId === event.payload.session.activeTurnId ? thread.latestTurn.assistantMessageId : null, + sourceProposedPlan: thread.pendingSourceProposedPlan, }) : thread.latestTurn, updatedAt: event.occurredAt, @@ -755,6 +762,7 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve startedAt: thread.latestTurn?.startedAt ?? event.payload.completedAt, completedAt: event.payload.completedAt, assistantMessageId: event.payload.assistantMessageId, + sourceProposedPlan: thread.pendingSourceProposedPlan, }) : thread.latestTurn; return { diff --git a/apps/web/src/types.ts b/apps/web/src/types.ts index ec5e01299d..0ebf150310 100644 --- a/apps/web/src/types.ts +++ b/apps/web/src/types.ts @@ -104,6 +104,7 @@ export interface Thread { archivedAt: string | null; updatedAt?: string | undefined; latestTurn: OrchestrationLatestTurn | null; + pendingSourceProposedPlan?: OrchestrationLatestTurn["sourceProposedPlan"]; branch: string | null; worktreePath: string | null; turnDiffSummaries: TurnDiffSummary[]; From ee8790e190574cf458442823c4b4f3233936ef4a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 30 Mar 2026 10:04:48 -0700 Subject: [PATCH 20/22] Clear stale source plans after thread revert - Reset pending source proposed plans on revert - Avoid carrying stale plan state into the next session-set event - Add regression coverage for revert/session transitions --- apps/web/src/routes/__root.tsx | 4 +- apps/web/src/store.test.ts | 70 ++++++++++++++++++++++++++++++++++ apps/web/src/store.ts | 1 + 3 files changed, 72 insertions(+), 3 deletions(-) diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 486a3560e9..bc78d6e971 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -158,9 +158,7 @@ function EventRouter() { const pathnameRef = useRef(pathname); const handledBootstrapThreadIdRef = useRef(null); - useEffect(() => { - pathnameRef.current = pathname; - }, [pathname]); + pathnameRef.current = pathname; useEffect(() => { const api = readNativeApi(); diff --git a/apps/web/src/store.test.ts b/apps/web/src/store.test.ts index db7d415196..98488679e7 100644 --- a/apps/web/src/store.test.ts +++ b/apps/web/src/store.test.ts @@ -622,4 +622,74 @@ describe("incremental orchestration updates", () => { TurnId.makeUnsafe("turn-1"), ]); }); + + it("clears pending source proposed plans after revert before a new session-set event", () => { + const thread = makeThread({ + latestTurn: { + turnId: TurnId.makeUnsafe("turn-2"), + state: "completed", + requestedAt: "2026-02-27T00:00:02.000Z", + startedAt: "2026-02-27T00:00:02.000Z", + completedAt: "2026-02-27T00:00:03.000Z", + assistantMessageId: MessageId.makeUnsafe("assistant-2"), + sourceProposedPlan: { + threadId: ThreadId.makeUnsafe("thread-source"), + planId: "plan-2" as never, + }, + }, + pendingSourceProposedPlan: { + threadId: ThreadId.makeUnsafe("thread-source"), + planId: "plan-2" as never, + }, + turnDiffSummaries: [ + { + turnId: TurnId.makeUnsafe("turn-1"), + completedAt: "2026-02-27T00:00:01.000Z", + status: "ready", + checkpointTurnCount: 1, + checkpointRef: CheckpointRef.makeUnsafe("ref-1"), + files: [], + }, + { + turnId: TurnId.makeUnsafe("turn-2"), + completedAt: "2026-02-27T00:00:03.000Z", + status: "ready", + checkpointTurnCount: 2, + checkpointRef: CheckpointRef.makeUnsafe("ref-2"), + files: [], + }, + ], + }); + const reverted = applyOrchestrationEvent( + makeState(thread), + makeEvent("thread.reverted", { + threadId: thread.id, + turnCount: 1, + }), + ); + + expect(reverted.threads[0]?.pendingSourceProposedPlan).toBeUndefined(); + + const next = applyOrchestrationEvent( + reverted, + makeEvent("thread.session-set", { + threadId: thread.id, + session: { + threadId: thread.id, + status: "running", + providerName: "codex", + runtimeMode: "full-access", + activeTurnId: TurnId.makeUnsafe("turn-3"), + lastError: null, + updatedAt: "2026-02-27T00:00:04.000Z", + }, + }), + ); + + expect(next.threads[0]?.latestTurn).toMatchObject({ + turnId: TurnId.makeUnsafe("turn-3"), + state: "running", + }); + expect(next.threads[0]?.latestTurn?.sourceProposedPlan).toBeUndefined(); + }); }); diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index 990a9e5b14..fb2531405e 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -808,6 +808,7 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve messages, proposedPlans, activities, + pendingSourceProposedPlan: undefined, latestTurn: latestCheckpoint === null ? null From f659da5c63cd7723639a96e03cd343258a9f5909 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 30 Mar 2026 11:32:36 -0700 Subject: [PATCH 21/22] Rebind turn diffs to the final assistant message - Prefer the authoritative assistant message ID when anchoring completion dividers - Rebind live turn diff summaries when the final assistant message arrives --- apps/web/src/components/ChatView.tsx | 31 ++---------------- apps/web/src/session-logic.test.ts | 32 +++++++++++++++++++ apps/web/src/session-logic.ts | 47 +++++++++++++++++++++++++++ apps/web/src/store.test.ts | 48 ++++++++++++++++++++++++++++ apps/web/src/store.ts | 28 ++++++++++++++++ 5 files changed, 158 insertions(+), 28 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 4e402ad58e..7562f845e2 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -43,6 +43,7 @@ import { replaceTextRange, } from "../composer-logic"; import { + deriveCompletionDividerBeforeEntryId, derivePendingApprovals, derivePendingUserInputs, derivePhase, @@ -1151,35 +1152,9 @@ export default function ChatView({ threadId }: ChatViewProps) { ]); const completionDividerBeforeEntryId = useMemo(() => { if (!latestTurnSettled) return null; - if (!activeLatestTurn?.startedAt) return null; - if (!activeLatestTurn.completedAt) return null; if (!completionSummary) return null; - - const turnStartedAt = Date.parse(activeLatestTurn.startedAt); - const turnCompletedAt = Date.parse(activeLatestTurn.completedAt); - if (Number.isNaN(turnStartedAt)) return null; - if (Number.isNaN(turnCompletedAt)) return null; - - let inRangeMatch: string | null = null; - let fallbackMatch: string | null = null; - for (const timelineEntry of timelineEntries) { - if (timelineEntry.kind !== "message") continue; - if (timelineEntry.message.role !== "assistant") continue; - const messageAt = Date.parse(timelineEntry.message.createdAt); - if (Number.isNaN(messageAt) || messageAt < turnStartedAt) continue; - fallbackMatch = timelineEntry.id; - if (messageAt <= turnCompletedAt) { - inRangeMatch = timelineEntry.id; - } - } - return inRangeMatch ?? fallbackMatch; - }, [ - activeLatestTurn?.completedAt, - activeLatestTurn?.startedAt, - completionSummary, - latestTurnSettled, - timelineEntries, - ]); + return deriveCompletionDividerBeforeEntryId(timelineEntries, activeLatestTurn); + }, [activeLatestTurn, completionSummary, latestTurnSettled, timelineEntries]); const gitCwd = activeProject ? projectScriptCwd({ project: { cwd: activeProject.cwd }, diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index c786ffc72b..e05c3b5e93 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -8,6 +8,7 @@ import { import { describe, expect, it } from "vitest"; import { + deriveCompletionDividerBeforeEntryId, deriveActiveWorkStartedAt, deriveActivePlanState, PROVIDER_OPTIONS, @@ -964,6 +965,37 @@ describe("deriveTimelineEntries", () => { }, }); }); + + it("anchors the completion divider to latestTurn.assistantMessageId before timestamp fallback", () => { + const entries = deriveTimelineEntries( + [ + { + id: MessageId.makeUnsafe("assistant-earlier"), + role: "assistant", + text: "progress update", + createdAt: "2026-02-23T00:00:01.000Z", + streaming: false, + }, + { + id: MessageId.makeUnsafe("assistant-final"), + role: "assistant", + text: "final answer", + createdAt: "2026-02-23T00:00:01.000Z", + streaming: false, + }, + ], + [], + [], + ); + + expect( + deriveCompletionDividerBeforeEntryId(entries, { + assistantMessageId: MessageId.makeUnsafe("assistant-final"), + startedAt: "2026-02-23T00:00:00.000Z", + completedAt: "2026-02-23T00:00:02.000Z", + }), + ).toBe("assistant-final"); + }); }); describe("deriveWorkLogEntries context window handling", () => { diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 83a95d6313..fc33827014 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -852,6 +852,53 @@ export function deriveTimelineEntries( ); } +export function deriveCompletionDividerBeforeEntryId( + timelineEntries: ReadonlyArray, + latestTurn: Pick< + OrchestrationLatestTurn, + "assistantMessageId" | "startedAt" | "completedAt" + > | null, +): string | null { + if (!latestTurn?.startedAt || !latestTurn.completedAt) { + return null; + } + + if (latestTurn.assistantMessageId) { + const exactMatch = timelineEntries.find( + (timelineEntry) => + timelineEntry.kind === "message" && + timelineEntry.message.role === "assistant" && + timelineEntry.message.id === latestTurn.assistantMessageId, + ); + if (exactMatch) { + return exactMatch.id; + } + } + + const turnStartedAt = Date.parse(latestTurn.startedAt); + const turnCompletedAt = Date.parse(latestTurn.completedAt); + if (Number.isNaN(turnStartedAt) || Number.isNaN(turnCompletedAt)) { + return null; + } + + let inRangeMatch: string | null = null; + let fallbackMatch: string | null = null; + for (const timelineEntry of timelineEntries) { + if (timelineEntry.kind !== "message" || timelineEntry.message.role !== "assistant") { + continue; + } + const messageAt = Date.parse(timelineEntry.message.createdAt); + if (Number.isNaN(messageAt) || messageAt < turnStartedAt) { + continue; + } + fallbackMatch = timelineEntry.id; + if (messageAt <= turnCompletedAt) { + inRangeMatch = timelineEntry.id; + } + } + return inRangeMatch ?? fallbackMatch; +} + export function inferCheckpointTurnCountByTurnId( summaries: TurnDiffSummary[], ): Record { diff --git a/apps/web/src/store.test.ts b/apps/web/src/store.test.ts index 98488679e7..6e909b38f0 100644 --- a/apps/web/src/store.test.ts +++ b/apps/web/src/store.test.ts @@ -509,6 +509,54 @@ describe("incremental orchestration updates", () => { expect(next.threads[0]?.latestTurn).toEqual(state.threads[0]?.latestTurn); }); + it("rebinds live turn diffs to the authoritative assistant message when it arrives later", () => { + const turnId = TurnId.makeUnsafe("turn-1"); + const state = makeState( + makeThread({ + latestTurn: { + turnId, + state: "completed", + requestedAt: "2026-02-27T00:00:00.000Z", + startedAt: "2026-02-27T00:00:00.000Z", + completedAt: "2026-02-27T00:00:02.000Z", + assistantMessageId: MessageId.makeUnsafe("assistant:turn-1"), + }, + turnDiffSummaries: [ + { + turnId, + completedAt: "2026-02-27T00:00:02.000Z", + status: "ready", + checkpointTurnCount: 1, + checkpointRef: CheckpointRef.makeUnsafe("checkpoint-1"), + assistantMessageId: MessageId.makeUnsafe("assistant:turn-1"), + files: [{ path: "src/app.ts", additions: 1, deletions: 0 }], + }, + ], + }), + ); + + const next = applyOrchestrationEvent( + state, + makeEvent("thread.message-sent", { + threadId: ThreadId.makeUnsafe("thread-1"), + messageId: MessageId.makeUnsafe("assistant-real"), + role: "assistant", + text: "final answer", + turnId, + streaming: false, + createdAt: "2026-02-27T00:00:03.000Z", + updatedAt: "2026-02-27T00:00:03.000Z", + }), + ); + + expect(next.threads[0]?.turnDiffSummaries[0]?.assistantMessageId).toBe( + MessageId.makeUnsafe("assistant-real"), + ); + expect(next.threads[0]?.latestTurn?.assistantMessageId).toBe( + MessageId.makeUnsafe("assistant-real"), + ); + }); + it("reverts messages, plans, activities, and checkpoints by retained turns", () => { const state = makeState( makeThread({ diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index fb2531405e..eff6a6fd07 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -232,6 +232,25 @@ function buildLatestTurn(params: { }; } +function rebindTurnDiffSummariesForAssistantMessage( + turnDiffSummaries: ReadonlyArray, + turnId: Thread["turnDiffSummaries"][number]["turnId"], + assistantMessageId: NonNullable["assistantMessageId"], +): Thread["turnDiffSummaries"] { + let changed = false; + const nextSummaries = turnDiffSummaries.map((summary) => { + if (summary.turnId !== turnId || summary.assistantMessageId === assistantMessageId) { + return summary; + } + changed = true; + return { + ...summary, + assistantMessageId: assistantMessageId ?? undefined, + }; + }); + return changed ? nextSummaries : [...turnDiffSummaries]; +} + function retainThreadMessagesAfterRevert( messages: ReadonlyArray, retainedTurnIds: ReadonlySet, @@ -612,6 +631,14 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve ) : [...thread.messages, message]; const cappedMessages = messages.slice(-MAX_THREAD_MESSAGES); + const turnDiffSummaries = + event.payload.role === "assistant" && event.payload.turnId !== null + ? rebindTurnDiffSummariesForAssistantMessage( + thread.turnDiffSummaries, + event.payload.turnId, + event.payload.messageId, + ) + : thread.turnDiffSummaries; const latestTurn: Thread["latestTurn"] = event.payload.role === "assistant" && event.payload.turnId !== null && @@ -646,6 +673,7 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve return { ...thread, messages: cappedMessages, + turnDiffSummaries, latestTurn, updatedAt: event.occurredAt, }; From a16234f0a2a239427ae7228d3a56b852910bf013 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 30 Mar 2026 19:09:19 +0000 Subject: [PATCH 22/22] Fix incremental thread event UI state seeding and remove redundant useShallow - Call syncThreads in applyEventBatch for thread.created/thread.deleted events so incrementally created threads get their seedVisitedAt populated in threadLastVisitedAtById, matching the snapshot recovery path behavior. - Remove unnecessary useShallow wrapper from store.projects and store.threads selectors in Sidebar. Zustand's default Object.is comparison already skips re-renders when these array references are unchanged, making the O(n) shallow comparison pure overhead. Applied via @cursor push command --- apps/web/src/components/Sidebar.tsx | 4 ++-- apps/web/src/routes/__root.tsx | 12 ++++++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index acb70afe10..c8bdb0957a 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -436,8 +436,8 @@ function SortableProjectItem({ } export default function Sidebar() { - const projects = useStore(useShallow((store) => store.projects)); - const serverThreads = useStore(useShallow((store) => store.threads)); + const projects = useStore((store) => store.projects); + const serverThreads = useStore((store) => store.threads); const { projectExpandedById, projectOrder, threadLastVisitedAtById } = useUiStateStore( useShallow((store) => ({ projectExpandedById: store.projectExpandedById, diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index bc78d6e971..4765b0a8e6 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -230,6 +230,18 @@ function EventRouter() { const projects = useStore.getState().projects; syncProjects(projects.map((project) => ({ id: project.id, cwd: project.cwd }))); } + const needsThreadUiSync = nextEvents.some( + (event) => event.type === "thread.created" || event.type === "thread.deleted", + ); + if (needsThreadUiSync) { + const threads = useStore.getState().threads; + syncThreads( + threads.map((thread) => ({ + id: thread.id, + seedVisitedAt: thread.updatedAt ?? thread.createdAt, + })), + ); + } const draftStore = useComposerDraftStore.getState(); for (const threadId of batchEffects.clearPromotedDraftThreadIds) { clearPromotedDraftThread(threadId);