diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index be9d5f9ac7..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, @@ -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, }), @@ -875,7 +955,7 @@ describe("ChatView timeline estimator parity (full app)", () => { useStore.setState({ projects: [], threads: [], - threadsHydrated: false, + bootstrapComplete: false, }); }); @@ -1800,21 +1880,16 @@ 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. - 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); + // 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( 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. @@ -2136,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)); - useComposerDraftStore.getState().clearDraftThread(promotedThreadId); + await promoteDraftThreadViaDomainEvent(promotedThreadId); const freshThreadPath = await triggerChatNewShortcutUntilPath( mounted.router, diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index bf72ec0b84..80c842d91f 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -1,7 +1,14 @@ -import { ThreadId } from "@t3tools/contracts"; -import { describe, expect, it } from "vitest"; +import { ProjectId, ThreadId, TurnId } from "@t3tools/contracts"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { useStore } from "../store"; -import { buildExpiredTerminalContextToastCopy, deriveComposerSendState } from "./ChatView.logic"; +import { + buildExpiredTerminalContextToastCopy, + createLocalDispatchSnapshot, + deriveComposerSendState, + hasServerAcknowledgedLocalDispatch, + waitForStartedServerThread, +} from "./ChatView.logic"; describe("deriveComposerSendState", () => { it("treats expired terminal pills as non-sendable content", () => { @@ -67,3 +74,290 @@ 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(); + vi.restoreAllMocks(); + 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("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(); + + 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 = { + 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 0a27fb203e..1821c65ed9 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -1,8 +1,9 @@ -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"; +import { useStore } from "../store"; import { filterTerminalContextsWithText, stripInlineTerminalContextPlaceholders, @@ -34,7 +35,6 @@ export function buildLocalDraftThread( createdAt: draftThread.createdAt, archivedAt: null, latestTurn: null, - lastVisitedAt: draftThread.createdAt, branch: draftThread.branch, worktreePath: draftThread.worktreePath, turnDiffSummaries: [], @@ -75,8 +75,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; @@ -161,3 +159,116 @@ export function buildExpiredTerminalContextToastCopy( description: "Re-add it if you want that terminal output included.", }; } + +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 getThread = () => useStore.getState().threads.find((thread) => thread.id === threadId); + const thread = getThread(); + + if (threadHasStarted(thread)) { + return true; + } + + return await new Promise((resolve) => { + let settled = false; + let timeoutId: ReturnType | null = null; + const finish = (result: boolean) => { + if (settled) { + return; + } + settled = true; + if (timeoutId !== null) { + globalThis.clearTimeout(timeoutId); + } + unsubscribe(); + resolve(result); + }; + + 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); + }); +} + +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 fbd332354a..7562f845e2 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"; @@ -42,6 +43,7 @@ import { replaceTextRange, } from "../composer-logic"; import { + deriveCompletionDividerBeforeEntryId, derivePendingApprovals, derivePendingUserInputs, derivePhase, @@ -64,6 +66,8 @@ import { type PendingUserInputDraftAnswer, } from "../pendingUserInput"; import { useStore } from "../store"; +import { useProjectById, useThreadById } from "../storeSelectors"; +import { useUiStateStore } from "../uiStateStore"; import { buildPlanImplementationThreadTitle, buildPlanImplementationPrompt, @@ -76,8 +80,12 @@ import { DEFAULT_THREAD_TERMINAL_ID, MAX_TERMINALS_PER_GROUP, type ChatMessage, + type SessionPhase, + type Thread, type TurnDiffSummary, } from "../types"; +import { LRUCache } from "../lib/lruCache"; + import { basenameOfPath } from "../vscode-icons"; import { useTheme } from "../hooks/useTheme"; import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; @@ -168,14 +176,18 @@ import { buildTemporaryWorktreeBranchName, cloneComposerImageForRetry, collectUserMessageBlobPreviewUrls, + createLocalDispatchSnapshot, deriveComposerSendState, + hasServerAcknowledgedLocalDispatch, LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, LastInvokedScriptByProjectSchema, + type LocalDispatchSnapshot, PullRequestDialogState, readFileAsDataUrl, revokeBlobPreviewUrl, revokeUserMessagePreviewUrls, - SendPhase, + threadHasStarted, + waitForStartedServerThread, } from "./ChatView.logic"; import { useLocalStorage } from "~/hooks/useLocalStorage"; @@ -190,6 +202,81 @@ 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 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; @@ -245,13 +332,81 @@ 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 threads = useStore((store) => store.threads); - const projects = useStore((store) => store.projects); - const markThreadVisited = useStore((store) => store.markThreadVisited); - const syncServerReadModel = useStore((store) => store.syncServerReadModel); + const serverThread = useThreadById(threadId); 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, @@ -330,8 +485,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([]); @@ -466,8 +619,7 @@ 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 = useProjectById(draftThread?.projectId); const localDraftError = serverThread ? null : (localDraftErrorsByThreadId[threadId] ?? null); const localDraftThread = useMemo( () => @@ -495,12 +647,25 @@ 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], ); const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null); - const activeProject = projects.find((p) => p.id === activeThread?.projectId); + const activeProject = useProjectById(activeThread?.projectId); const openPullRequestDialog = useCallback( (reference?: string) => { @@ -595,33 +760,28 @@ 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; 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; @@ -664,15 +824,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), @@ -735,12 +886,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), @@ -752,6 +903,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 || @@ -980,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 }, @@ -1230,7 +1376,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 +1390,7 @@ export default function ChatView({ threadId }: ChatViewProps) { }; }); }, - [setStoreThreadError, threads], + [setStoreThreadError], ); const focusComposer = useCallback(() => { @@ -1411,7 +1557,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, @@ -2020,15 +2166,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; @@ -2161,37 +2306,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; @@ -2529,7 +2643,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]; @@ -2603,7 +2717,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, @@ -2715,7 +2829,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", @@ -2772,7 +2886,7 @@ export default function ChatView({ threadId }: ChatViewProps) { }); sendInFlightRef.current = false; if (!turnStartSucceeded) { - resetSendPhase(); + resetLocalDispatch(); } }; @@ -2971,7 +3085,7 @@ export default function ChatView({ threadId }: ChatViewProps) { }); sendInFlightRef.current = true; - beginSendPhase("sending-turn"); + beginLocalDispatch({ preparingWorktree: false }); setThreadError(threadIdForSend, null); setOptimisticUserMessages((existing) => [ ...existing, @@ -3040,19 +3154,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, @@ -3094,10 +3208,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 @@ -3132,9 +3246,10 @@ export default function ChatView({ threadId }: ChatViewProps) { createdAt, }); }) - .then(() => api.orchestration.getSnapshot()) - .then((snapshot) => { - syncServerReadModel(snapshot); + .then(() => { + return waitForStartedServerThread(nextThreadId); + }) + .then(() => { // Signal that the plan sidebar should open on the new thread. planSidebarOpenOnNextThreadRef.current = true; return navigate({ @@ -3150,12 +3265,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", @@ -3168,18 +3277,17 @@ export default function ChatView({ threadId }: ChatViewProps) { activeProject, activeProposedPlan, activeThread, - beginSendPhase, + beginLocalDispatch, isConnecting, isSendBusy, isServerThread, navigate, - resetSendPhase, + resetLocalDispatch, runtimeMode, selectedPromptEffort, selectedModelSelection, selectedProvider, selectedProviderModels, - syncServerReadModel, selectedModel, ]); 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/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index d7f93f371d..6b52da1922 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, @@ -16,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, @@ -27,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", @@ -99,6 +100,50 @@ 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"), + ]); + }); + + 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", () => { it("resolves adjacent thread ids in ordered sidebar traversal", () => { const threads = [ @@ -467,7 +512,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 f911775446..b4c09a65c9 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"; @@ -43,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; @@ -69,6 +74,34 @@ 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 emittedPreferredIds = new Set(); + const ordered = preferredIds.flatMap((id) => { + if (emittedPreferredIds.has(id)) { + return []; + } + const item = itemsById.get(id); + if (!item) { + return []; + } + emittedPreferredIds.add(id); + return [item]; + }); + const remaining = items.filter((item) => !preferredIdSet.has(getId(item))); + return [...ordered, ...remaining]; +} + export function getVisibleSidebarThreadIds( renderedProjects: readonly { renderedThreads: readonly { @@ -237,15 +270,15 @@ 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; - hiddenThreads: Thread[]; - visibleThreads: Thread[]; + visibleThreads: T[]; + hiddenThreads: T[]; } { const { activeThreadId, isThreadListExpanded, previewLimit, threads } = input; const hasHiddenThreads = threads.length > previewLimit; @@ -292,9 +325,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; @@ -322,7 +359,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); @@ -335,7 +372,7 @@ export function sortThreadsForSidebar< } export function getFallbackThreadIdAfterDelete< - T extends Pick, + T extends Pick & SidebarThreadSortInput, >(input: { threads: readonly T[]; deletedThreadId: T["id"]; @@ -379,7 +416,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 dbff140b49..c8bdb0957a 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, @@ -55,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, @@ -113,6 +115,7 @@ import { resolveSidebarNewThreadEnvMode, resolveThreadRowClassName, resolveThreadStatusPill, + orderItemsByPreferredIds, shouldClearThreadSelectionOnMouseDown, sortProjectsForSidebar, sortThreadsForSidebar, @@ -120,6 +123,7 @@ import { import { SidebarUpdatePill } from "./sidebar/SidebarUpdatePill"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { useSettings, useUpdateSettings } from "~/hooks/useSettings"; +import type { Project, Thread } from "../types"; const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; const THREAD_PREVIEW_LIMIT = 6; @@ -136,6 +140,80 @@ const SIDEBAR_LIST_ANIMATION_OPTIONS = { duration: 180, easing: "ease-out", } as const; + +type SidebarThreadSnapshot = Pick< + Thread, + | "activities" + | "archivedAt" + | "branch" + | "createdAt" + | "id" + | "interactionMode" + | "latestTurn" + | "projectId" + | "proposedPlans" + | "session" + | "title" + | "updatedAt" + | "worktreePath" +> & { + lastVisitedAt?: string | undefined; + latestUserMessageAt: string | null; +}; + +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; + + 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, + lastVisitedAt: string | undefined, +): SidebarThreadSnapshot { + const cached = sidebarThreadSnapshotCache.get(thread); + if (cached && cached.lastVisitedAt === lastVisitedAt) { + return cached.snapshot; + } + + 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, + branch: thread.branch, + worktreePath: thread.worktreePath, + activities: thread.activities, + proposedPlans: thread.proposedPlans, + latestUserMessageAt: getLatestUserMessageAt(thread), + }; + sidebarThreadSnapshotCache.set(thread, { lastVisitedAt, snapshot }); + return snapshot; +} interface TerminalStatusIndicator { label: "Terminal process running"; colorClass: string; @@ -359,10 +437,17 @@ function SortableProjectItem({ export default function Sidebar() { const projects = useStore((store) => store.projects); - const threads = useStore((store) => store.threads); - const markThreadUnread = useStore((store) => store.markThreadUnread); - const toggleProject = useStore((store) => store.toggleProject); - const reorderProjects = useStore((store) => store.reorderProjects); + const serverThreads = useStore((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, @@ -416,6 +501,28 @@ export default function Sidebar() { const platform = navigator.platform; const shouldBrowseForProjectImmediately = isElectron && !isLinuxDesktop; const shouldShowProjectPathEntry = addingProject && !shouldBrowseForProjectImmediately; + const orderedProjects = useMemo(() => { + return orderItemsByPreferredIds({ + items: projects, + preferredIds: projectOrder, + getId: (project) => project.id, + }); + }, [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], @@ -750,7 +857,7 @@ export default function Sidebar() { } if (clicked === "mark-unread") { - markThreadUnread(threadId); + markThreadUnread(threadId, thread.latestTurn?.completedAt); return; } if (clicked === "copy-path") { @@ -812,7 +919,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; @@ -843,6 +951,7 @@ export default function Sidebar() { markThreadUnread, removeFromSelection, selectedThreadIds, + threads, ], ); @@ -985,12 +1094,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( @@ -1050,8 +1159,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/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/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index ffb4b5cf67..1547035bf4 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -1,29 +1,37 @@ 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 { useShallow } from "zustand/react/shallow"; import { type DraftThreadEnvMode, type DraftThreadState, useComposerDraftStore, } from "../composerDraftStore"; import { newThreadId } from "../lib/utils"; +import { orderItemsByPreferredIds } from "../components/Sidebar.logic"; import { useStore } from "../store"; +import { useThreadById } from "../storeSelectors"; +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 = useThreadById(routeThreadId); const activeDraftThread = useComposerDraftStore((store) => routeThreadId ? (store.draftThreadsByThreadId[routeThreadId] ?? null) : null, ); - - const activeThread = routeThreadId - ? threads.find((thread) => thread.id === routeThreadId) - : undefined; + const orderedProjects = useMemo(() => { + return orderItemsByPreferredIds({ + items: projectIds, + preferredIds: projectOrder, + getId: (projectId) => projectId, + }); + }, [projectIds, projectOrder]); const handleNewThread = useCallback( ( @@ -111,8 +119,8 @@ export function useHandleNewThread() { return { activeDraftThread, activeThread, + defaultProjectId: orderedProjects[0] ?? null, handleNewThread, - projects, 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/orchestrationEventEffects.test.ts b/apps/web/src/orchestrationEventEffects.test.ts new file mode 100644 index 0000000000..263610bb95 --- /dev/null +++ b/apps/web/src/orchestrationEventEffects.test.ts @@ -0,0 +1,108 @@ +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.clearPromotedDraftThreadIds).toEqual([createdThreadId]); + expect(effects.clearDeletedThreadIds).toEqual([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.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 new file mode 100644 index 0000000000..d4dda76d9e --- /dev/null +++ b/apps/web/src/orchestrationEventEffects.ts @@ -0,0 +1,76 @@ +import type { OrchestrationEvent, ThreadId } from "@t3tools/contracts"; + +export interface OrchestrationBatchEffects { + clearPromotedDraftThreadIds: ThreadId[]; + clearDeletedThreadIds: ThreadId[]; + removeTerminalStateThreadIds: ThreadId[]; + needsProviderInvalidation: boolean; +} + +export function deriveOrchestrationBatchEffects( + events: readonly OrchestrationEvent[], +): OrchestrationBatchEffects { + const threadLifecycleEffects = new Map< + ThreadId, + { + clearPromotedDraft: boolean; + clearDeletedThread: 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, { + clearPromotedDraft: true, + clearDeletedThread: false, + removeTerminalState: false, + }); + break; + } + + case "thread.deleted": { + threadLifecycleEffects.set(event.payload.threadId, { + clearPromotedDraft: false, + clearDeletedThread: true, + removeTerminalState: true, + }); + break; + } + + default: { + break; + } + } + } + + const clearPromotedDraftThreadIds: ThreadId[] = []; + const clearDeletedThreadIds: ThreadId[] = []; + const removeTerminalStateThreadIds: ThreadId[] = []; + for (const [threadId, effect] of threadLifecycleEffects) { + if (effect.clearPromotedDraft) { + clearPromotedDraftThreadIds.push(threadId); + } + if (effect.clearDeletedThread) { + clearDeletedThreadIds.push(threadId); + } + if (effect.removeTerminalState) { + removeTerminalStateThreadIds.push(threadId); + } + } + + return { + clearPromotedDraftThreadIds, + clearDeletedThreadIds, + removeTerminalStateThreadIds, + needsProviderInvalidation, + }; +} diff --git a/apps/web/src/orchestrationRecovery.test.ts b/apps/web/src/orchestrationRecovery.test.ts new file mode 100644 index 0000000000..bea16cdbce --- /dev/null +++ b/apps/web/src/orchestrationRecovery.test.ts @@ -0,0 +1,100 @@ +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("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(); + + 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..ee81d5d539 --- /dev/null +++ b/apps/web/src/orchestrationRecovery.ts @@ -0,0 +1,136 @@ +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, + }; + let replayStartSequence: number | null = 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; + 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; + }, + }; +} diff --git a/apps/web/src/router.ts b/apps/web/src/router.ts index 0192ee0c6c..16b78e69dc 100644 --- a/apps/web/src/router.ts +++ b/apps/web/src/router.ts @@ -1,11 +1,8 @@ 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"; -import { StoreProvider } from "./store"; - -type RouterHistory = NonNullable[0]["history"]>; export function getRouter(history: RouterHistory) { const queryClient = new QueryClient(); @@ -16,12 +13,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 e99ec50226..4765b0a8e6 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, @@ -17,8 +17,13 @@ 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 { useUiStateStore } from "../uiStateStore"; import { useTerminalStateStore } from "../terminalStateStore"; import { terminalRunningSubprocessFromEvent } from "../terminalActivity"; import { onServerConfigUpdated, onServerProvidersUpdated, onServerWelcome } from "../wsNativeApi"; @@ -26,6 +31,8 @@ import { migrateLocalSettingsToServer } from "../hooks/useSettings"; 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; @@ -135,8 +142,13 @@ 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, ); @@ -152,56 +164,40 @@ function EventRouter() { const api = readNativeApi(); if (!api) return; let disposed = false; - let latestSequence = 0; - let syncing = false; - let pending = false; + const recovery = createOrchestrationRecoveryCoordinator(); 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 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, ) 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 +206,113 @@ function EventRouter() { }, ); - const unsubDomainEvent = api.orchestration.onDomainEvent((event) => { - if (event.sequence <= latestSequence) { + const applyEventBatch = (events: ReadonlyArray) => { + const nextEvents = recovery.markEventBatchApplied(events); + if (nextEvents.length === 0) { return; } - latestSequence = event.sequence; - if (event.type === "thread.turn-diff-completed" || event.type === "thread.reverted") { + + 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; + void queryInvalidationThrottler.maybeExecute(); + } + + applyOrchestrationEvents(nextEvents); + if (needsProjectUiSync) { + 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); + } + for (const threadId of batchEffects.clearDeletedThreadIds) { + draftStore.clearDraftThread(threadId); + clearThreadUi(threadId); + } + for (const threadId of batchEffects.removeTerminalStateThreadIds) { + removeTerminalState(threadId); + } + }; + + const recoverFromSequenceGap = async (): Promise => { + if (!recovery.beginReplayRecovery("sequence-gap")) { + return; + } + + try { + const events = await api.orchestration.replayEvents(recovery.getState().latestSequence); + if (!disposed) { + applyEventBatch(events); + } + } catch { + recovery.failReplayRecovery(); + void fallbackToSnapshotRecovery(); + return; + } + + if (!disposed && recovery.completeReplayRecovery()) { + void recoverFromSequenceGap(); + } + }; + + const runSnapshotRecovery = async (reason: "bootstrap" | "replay-failed"): Promise => { + if (!recovery.beginSnapshotRecovery(reason)) { + return; + } + + try { + const snapshot = await api.orchestration.getSnapshot(); + if (!disposed) { + syncServerReadModel(snapshot); + reconcileSnapshotDerivedState(); + if (recovery.completeSnapshotRecovery(snapshot.snapshotSequence)) { + void recoverFromSequenceGap(); + } + } + } catch { + // Keep prior state and wait for welcome or a later replay attempt. + recovery.failSnapshotRecovery(); + } + }; + + const bootstrapFromSnapshot = async (): Promise => { + await runSnapshotRecovery("bootstrap"); + }; + + const fallbackToSnapshotRecovery = async (): Promise => { + await runSnapshotRecovery("replay-failed"); + }; + + const unsubDomainEvent = api.orchestration.onDomainEvent((event) => { + const action = recovery.classifyDomainEvent(event.sequence); + if (action === "apply") { + applyEventBatch([event]); + return; + } + if (action === "recover") { + void recoverFromSequenceGap(); } - domainEventFlushThrottler.maybeExecute(); }); const unsubTerminalEvent = api.terminal.onEvent((event) => { const hasRunningSubprocess = terminalRunningSubprocessFromEvent(event); @@ -237,7 +331,7 @@ function EventRouter() { // Migrate old localStorage settings to server on first connect migrateLocalSettingsToServer(); void (async () => { - await syncSnapshot(); + await bootstrapFromSnapshot(); if (disposed) { return; } @@ -319,7 +413,7 @@ function EventRouter() { return () => { disposed = true; needsProviderInvalidation = false; - domainEventFlushThrottler.cancel(); + queryInvalidationThrottler.cancel(); unsubDomainEvent(); unsubTerminalEvent(); unsubWelcome(); @@ -327,11 +421,16 @@ function EventRouter() { unsubProvidersUpdated(); }; }, [ + applyOrchestrationEvents, navigate, queryClient, + removeTerminalState, removeOrphanedTerminalStates, + clearThreadUi, setProjectExpanded, + syncProjects, syncServerReadModel, + syncThreads, ]); return null; 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/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/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 db62bad523..6e909b38f0 100644 --- a/apps/web/src/store.test.ts +++ b/apps/web/src/store.test.ts @@ -1,13 +1,22 @@ 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, + syncServerReadModel, + type AppState, +} from "./store"; import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type Thread } from "./types"; function makeThread(overrides: Partial = {}): Thread { @@ -48,15 +57,41 @@ function makeState(thread: Thread): AppState { provider: "codex", model: "gpt-5-codex", }, - expanded: true, scripts: [], }, ], threads: [thread], - threadsHydrated: true, + bootstrapComplete: true, }; } +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"), @@ -126,97 +161,18 @@ 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: [], - threadsHydrated: true, +describe("store read model sync", () => { + it("marks bootstrap complete after snapshot sync", () => { + const initialState: AppState = { + ...makeState(makeThread()), + bootstrapComplete: false, }; - const next = reorderProjects(state, project1, project3); + const next = syncServerReadModel(initialState, makeReadModel(makeReadModelThread({}))); - expect(next.projects.map((project) => project.id)).toEqual([project2, project3, project1]); + expect(next.bootstrapComplete).toBe(true); }); -}); -describe("store read model sync", () => { it("preserves claude model slugs without an active session", () => { const initialState = makeState(makeThread()); const readModel = makeReadModel( @@ -287,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"); @@ -301,7 +257,6 @@ describe("store read model sync", () => { provider: "codex", model: DEFAULT_MODEL_BY_PROVIDER.codex, }, - expanded: true, scripts: [], }, { @@ -312,12 +267,11 @@ describe("store read model sync", () => { provider: "codex", model: DEFAULT_MODEL_BY_PROVIDER.codex, }, - expanded: true, scripts: [], }, ], threads: [], - threadsHydrated: true, + bootstrapComplete: true, }; const readModel: OrchestrationReadModel = { snapshotSequence: 2, @@ -344,6 +298,446 @@ 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]); + }); +}); + +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); + + 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("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"), + 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("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("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({ + 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"), + ]); + }); + + 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 a5beb5b1bf..eff6a6fd07 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -1,98 +1,36 @@ -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"; import { create } from "zustand"; import { type ChatMessage, type Project, type Thread } from "./types"; -import { Debouncer } from "@tanstack/react-pacer"; // ── State ──────────────────────────────────────────────────────────── export interface AppState { projects: Project[]; threads: Thread[]; - threadsHydrated: boolean; + 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: [], - threadsHydrated: false, + bootstrapComplete: false, }; -const persistedExpandedProjectCwds = new Set(); -const persistedProjectOrderCwds: string[] = []; - -// ── 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 }); +const MAX_THREAD_MESSAGES = 2_000; +const MAX_THREAD_CHECKPOINTS = 500; +const MAX_THREAD_PROPOSED_PLANS = 200; +const MAX_THREAD_ACTIVITIES = 500; // ── Pure helpers ────────────────────────────────────────────────────── @@ -111,66 +49,295 @@ function updateThread( return changed ? next : threads; } -function mapProjectsFromReadModel( - incoming: OrchestrationReadModel["projects"], - previous: Project[], +function updateProject( + projects: Project[], + projectId: Project["id"], + updater: (project: Project) => 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; + 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), + }; +} - const mappedProjects = incoming.map((project) => { - const existing = previousById.get(project.id) ?? previousByCwd.get(project.workspaceRoot); +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): 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, + pendingSourceProposedPlan: thread.latestTurn?.sourceProposedPlan, + branch: thread.branch, + worktreePath: thread.worktreePath, + turnDiffSummaries: thread.checkpoints.map(mapTurnDiffSummary), + activities: thread.activities.map((activity) => ({ ...activity })), + }; +} + +function mapProject(project: OrchestrationReadModel["projects"][number]): Project { + return { + id: project.id, + name: project.title, + cwd: project.workspaceRoot, + defaultModelSelection: project.defaultModelSelection + ? normalizeModelSelection(project.defaultModelSelection) + : null, + 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"]; + sourceProposedPlan?: Thread["pendingSourceProposedPlan"]; +}): NonNullable { + const resolvedPlan = + params.previous?.turnId === params.turnId + ? params.previous.sourceProposedPlan + : params.sourceProposedPlan; + return { + turnId: params.turnId, + state: params.state, + requestedAt: params.requestedAt, + startedAt: params.startedAt, + completedAt: params.completedAt, + assistantMessageId: params.assistantMessageId, + ...(resolvedPlan ? { sourceProposedPlan: resolvedPlan } : {}), + }; +} + +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 { - 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; + ...summary, + assistantMessageId: assistantMessageId ?? undefined, + }; }); + return changed ? nextSummaries : [...turnDiffSummaries]; +} + +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), + ); +} - 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 retainThreadProposedPlansAfterRevert( + proposedPlans: ReadonlyArray, + retainedTurnIds: ReadonlySet, +): Thread["proposedPlans"] { + return proposedPlans.filter( + (proposedPlan) => proposedPlan.turnId === null || retainedTurnIds.has(proposedPlan.turnId), + ); } function toLegacySessionStatus( @@ -234,167 +401,506 @@ 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) => { - 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 })), - }; - }); + 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, threads, - threadsHydrated: true, + bootstrapComplete: true, }; } -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; +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 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) => + index === existingIndex ? nextProject : project, + ) + : [...state.projects, nextProject]; + return { ...state, projects }; } - 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 }; -} + 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 }; + } -export function toggleProject(state: AppState, projectId: Project["id"]): AppState { - return { - ...state, - projects: state.projects.map((p) => (p.id === projectId ? { ...p, expanded: !p.expanded } : p)), - }; -} + case "project.deleted": { + const projects = state.projects.filter((project) => project.id !== event.payload.projectId); + return projects.length === state.projects.length ? state : { ...state, projects }; + } -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; + 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, + }); + const threads = existing + ? state.threads.map((thread) => (thread.id === nextThread.id ? nextThread : thread)) + : [...state.threads, nextThread]; + 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 }; + } + + 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 }; + } + + 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 }; + } + + 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 }; + } + + 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 }; + } + + 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 }; + } + + 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, + pendingSourceProposedPlan: event.payload.sourceProposedPlan, + updatedAt: event.occurredAt, + })); + return threads === state.threads ? state : { ...state, threads }; + } + + 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 }; + } + + 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 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 && + (thread.latestTurn === null || thread.latestTurn.turnId === event.payload.turnId) + ? 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, + sourceProposedPlan: thread.pendingSourceProposedPlan, + 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, + turnDiffSummaries, + latestTurn, + updatedAt: event.occurredAt, + }; + }); + return threads === state.threads ? state : { ...state, threads }; + } + + 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, + sourceProposedPlan: thread.pendingSourceProposedPlan, + }) + : thread.latestTurn, + updatedAt: event.occurredAt, + })); + return threads === state.threads ? state : { ...state, threads }; + } + + 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 }; + } + + 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 }; + } + + 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); + 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, + sourceProposedPlan: thread.pendingSourceProposedPlan, + }) + : thread.latestTurn; + return { + ...thread, + turnDiffSummaries, + latestTurn, + updatedAt: event.occurredAt, + }; + }); + return threads === state.threads ? state : { ...state, threads }; + } + + 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, + pendingSourceProposedPlan: undefined, + 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 }; + } + + 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 }; + } + + case "thread.approval-response-requested": + case "thread.user-input-response-requested": + return state; + } + + return state; } -export function reorderProjects( +export function applyOrchestrationEvents( state: AppState, - draggedProjectId: Project["id"], - targetProjectId: Project["id"], + events: ReadonlyArray, ): 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 }; + 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 | null | undefined) => + (state: AppState): Thread | undefined => + 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) => { if (t.error === error) return t; @@ -426,44 +932,18 @@ export function setThreadBranch( interface AppStore extends AppState { syncServerReadModel: (readModel: OrchestrationReadModel) => 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; + applyOrchestrationEvent: (event: OrchestrationEvent) => void; + applyOrchestrationEvents: (events: ReadonlyArray) => 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)), - 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)), + applyOrchestrationEvent: (event) => set((state) => applyOrchestrationEvent(state, event)), + applyOrchestrationEvents: (events) => set((state) => applyOrchestrationEvents(state, events)), 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/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); +} 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( diff --git a/apps/web/src/types.ts b/apps/web/src/types.ts index e6cb1efea6..0ebf150310 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; @@ -82,7 +83,6 @@ export interface Project { name: string; cwd: string; defaultModelSelection: ModelSelection | null; - expanded: boolean; createdAt?: string | undefined; updatedAt?: string | undefined; scripts: ProjectScript[]; @@ -104,7 +104,7 @@ export interface Thread { archivedAt: string | null; updatedAt?: string | undefined; latestTurn: OrchestrationLatestTurn | null; - lastVisitedAt?: string | undefined; + pendingSourceProposedPlan?: OrchestrationLatestTurn["sourceProposedPlan"]; 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(); + }); +}