From 8e5c24474044bd324ce52303faded776427d8cb3 Mon Sep 17 00:00:00 2001 From: justsomelegs <145564979+justsomelegs@users.noreply.github.com> Date: Fri, 3 Apr 2026 09:30:27 +0100 Subject: [PATCH 1/2] Refactor web store into atomic slices --- apps/web/src/components/BranchToolbar.tsx | 14 +- apps/web/src/components/ChatView.browser.tsx | 18 +- apps/web/src/components/ChatView.logic.ts | 6 +- apps/web/src/components/ChatView.tsx | 59 +- apps/web/src/components/DiffPanel.tsx | 7 +- apps/web/src/components/GitActionsControl.tsx | 2 +- .../components/KeybindingsToast.browser.tsx | 18 +- apps/web/src/components/Sidebar.tsx | 16 +- .../components/settings/SettingsPanels.tsx | 19 +- apps/web/src/hooks/useHandleNewThread.ts | 8 +- apps/web/src/hooks/useThreadActions.ts | 13 +- apps/web/src/routes/__root.tsx | 13 +- apps/web/src/routes/_chat.$threadId.tsx | 2 +- apps/web/src/store.test.ts | 308 ++++-- apps/web/src/store.ts | 894 +++++++++++++----- apps/web/src/storeSelectors.ts | 158 +++- apps/web/src/types.ts | 21 + 17 files changed, 1167 insertions(+), 409 deletions(-) diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 79c453c0f5..92929f78fc 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -34,15 +34,15 @@ export default function BranchToolbar({ onCheckoutPullRequestRequest, onComposerFocusRequest, }: BranchToolbarProps) { - const threads = useStore((store) => store.threads); - const projects = useStore((store) => store.projects); + const serverThread = useStore((store) => store.threadShellById[threadId]); + const serverSession = useStore((store) => store.threadSessionById[threadId] ?? null); const setThreadBranchAction = useStore((store) => store.setThreadBranch); const draftThread = useComposerDraftStore((store) => store.getDraftThread(threadId)); const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); - - const serverThread = threads.find((thread) => thread.id === threadId); const activeProjectId = serverThread?.projectId ?? draftThread?.projectId ?? null; - const activeProject = projects.find((project) => project.id === activeProjectId); + const activeProject = useStore((store) => + activeProjectId ? store.projectById[activeProjectId] : undefined, + ); const activeThreadId = serverThread?.id ?? (draftThread ? threadId : undefined); const activeThreadBranch = serverThread?.branch ?? draftThread?.branch ?? null; const activeWorktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null; @@ -60,7 +60,7 @@ export default function BranchToolbar({ const api = readNativeApi(); // If the effective cwd is about to change, stop the running session so the // next message creates a new one with the correct cwd. - if (serverThread?.session && worktreePath !== activeWorktreePath && api) { + if (serverSession && worktreePath !== activeWorktreePath && api) { void api.orchestration .dispatchCommand({ type: "thread.session.stop", @@ -96,7 +96,7 @@ export default function BranchToolbar({ }, [ activeThreadId, - serverThread?.session, + serverSession, activeWorktreePath, hasServerThread, setThreadBranchAction, diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index b0263cbf40..85ef2a40a3 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -1139,8 +1139,22 @@ describe("ChatView timeline estimator parity (full app)", () => { stickyActiveProvider: null, }); useStore.setState({ - projects: [], - threads: [], + projectIds: [], + projectById: {}, + threadIds: [], + threadIdsByProjectId: {}, + threadShellById: {}, + threadSessionById: {}, + threadTurnStateById: {}, + messageIdsByThreadId: {}, + messageByThreadId: {}, + activityIdsByThreadId: {}, + activityByThreadId: {}, + proposedPlanIdsByThreadId: {}, + proposedPlanByThreadId: {}, + turnDiffIdsByThreadId: {}, + turnDiffSummaryByThreadId: {}, + sidebarThreadSummaryById: {}, bootstrapComplete: false, }); }); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index ca2a671c11..6a0aa4d0c8 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -3,7 +3,7 @@ import { type ChatMessage, type SessionPhase, type Thread, type ThreadSession } import { randomUUID } from "~/lib/utils"; import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore"; import { Schema } from "effect"; -import { useStore } from "../store"; +import { selectThreadById, useStore } from "../store"; import { filterTerminalContextsWithText, stripInlineTerminalContextPlaceholders, @@ -202,7 +202,7 @@ export async function waitForStartedServerThread( threadId: ThreadId, timeoutMs = 1_000, ): Promise { - const getThread = () => useStore.getState().threads.find((thread) => thread.id === threadId); + const getThread = () => selectThreadById(threadId)(useStore.getState()); const thread = getThread(); if (threadHasStarted(thread)) { @@ -225,7 +225,7 @@ export async function waitForStartedServerThread( }; const unsubscribe = useStore.subscribe((state) => { - if (!threadHasStarted(state.threads.find((thread) => thread.id === threadId))) { + if (!threadHasStarted(selectThreadById(threadId)(state))) { return; } finish(true); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index fd2f371005..436af61404 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -64,7 +64,7 @@ import { type PendingUserInputDraftAnswer, } from "../pendingUserInput"; import { useStore } from "../store"; -import { useProjectById, useThreadById } from "../storeSelectors"; +import { createThreadSelector } from "../storeSelectors"; import { useUiStateStore } from "../uiStateStore"; import { buildPlanImplementationThreadTitle, @@ -219,7 +219,7 @@ const threadPlanCatalogCache = new LRUCache<{ entry: ThreadPlanCatalogEntry; }>(MAX_THREAD_PLAN_CATALOG_CACHE_ENTRIES, MAX_THREAD_PLAN_CATALOG_CACHE_MEMORY_BYTES); -function estimateThreadPlanCatalogEntrySize(thread: Thread): number { +function estimateThreadPlanCatalogEntrySize(thread: Pick): number { return Math.max( 64, thread.id.length + @@ -235,7 +235,9 @@ function estimateThreadPlanCatalogEntrySize(thread: Thread): number { ); } -function toThreadPlanCatalogEntry(thread: Thread): ThreadPlanCatalogEntry { +function toThreadPlanCatalogEntry( + thread: Pick, +): ThreadPlanCatalogEntry { const cached = threadPlanCatalogCache.get(thread.id); if (cached && cached.proposedPlans === thread.proposedPlans) { return cached.entry; @@ -258,13 +260,29 @@ function toThreadPlanCatalogEntry(thread: Thread): ThreadPlanCatalogEntry { function useThreadPlanCatalog(threadIds: readonly ThreadId[]): ThreadPlanCatalogEntry[] { const selector = useMemo(() => { - let previousThreads: Array | null = null; + let previousThreads: Array< + { id: ThreadId; proposedPlans: Thread["proposedPlans"] } | undefined + > | null = null; let previousEntries: ThreadPlanCatalogEntry[] = []; - return (state: { threads: Thread[] }): ThreadPlanCatalogEntry[] => { - const nextThreads = threadIds.map((threadId) => - state.threads.find((thread) => thread.id === threadId), - ); + return (state: { + threadShellById: Record; + proposedPlanIdsByThreadId: Record; + proposedPlanByThreadId: Record>; + }): ThreadPlanCatalogEntry[] => { + const nextThreads = threadIds.map((threadId) => { + if (!state.threadShellById[threadId]) { + return undefined; + } + return { + id: threadId, + proposedPlans: + state.proposedPlanIdsByThreadId[threadId]?.flatMap((planId) => { + const plan = state.proposedPlanByThreadId[threadId]?.[planId]; + return plan ? [plan] : []; + }) ?? [], + }; + }); const cachedThreads = previousThreads; if ( cachedThreads && @@ -426,11 +444,17 @@ function PersistentThreadTerminalDrawer({ closeShortcutLabel, onAddTerminalContext, }: PersistentThreadTerminalDrawerProps) { - const serverThread = useThreadById(threadId); + const serverThread = useStore(useMemo(() => createThreadSelector(threadId), [threadId])); const draftThread = useComposerDraftStore( (store) => store.draftThreadsByThreadId[threadId] ?? null, ); - const project = useProjectById(serverThread?.projectId ?? draftThread?.projectId); + const project = useStore((state) => + serverThread?.projectId + ? state.projectById[serverThread.projectId] + : draftThread?.projectId + ? state.projectById[draftThread.projectId] + : undefined, + ); const terminalState = useTerminalStateStore((state) => selectThreadTerminalState(state.terminalStateByThreadId, threadId), ); @@ -565,7 +589,7 @@ function PersistentThreadTerminalDrawer({ } export default function ChatView({ threadId }: ChatViewProps) { - const serverThread = useThreadById(threadId); + const serverThread = useStore(useMemo(() => createThreadSelector(threadId), [threadId])); const setStoreThreadError = useStore((store) => store.setError); const setStoreThreadBranch = useStore((store) => store.setThreadBranch); const markThreadVisited = useUiStateStore((store) => store.markThreadVisited); @@ -742,8 +766,7 @@ export default function ChatView({ threadId }: ChatViewProps) { const storeNewTerminal = useTerminalStateStore((s) => s.newTerminal); const storeSetActiveTerminal = useTerminalStateStore((s) => s.setActiveTerminal); const storeCloseTerminal = useTerminalStateStore((s) => s.closeTerminal); - const threads = useStore((state) => state.threads); - const serverThreadIds = useMemo(() => threads.map((thread) => thread.id), [threads]); + const serverThreadIds = useStore((state) => state.threadIds); const draftThreadsByThreadId = useComposerDraftStore((store) => store.draftThreadsByThreadId); const draftThreadIds = useMemo( () => Object.keys(draftThreadsByThreadId) as ThreadId[], @@ -804,7 +827,9 @@ export default function ChatView({ threadId }: ChatViewProps) { [composerTerminalContexts, removeComposerDraftTerminalContext, setPrompt, threadId], ); - const fallbackDraftProject = useProjectById(draftThread?.projectId); + const fallbackDraftProject = useStore((state) => + draftThread?.projectId ? state.projectById[draftThread.projectId] : undefined, + ); const localDraftError = serverThread ? null : (localDraftErrorsByThreadId[threadId] ?? null); const localDraftThread = useMemo( () => @@ -869,7 +894,9 @@ export default function ChatView({ threadId }: ChatViewProps) { }); }, [activeThreadId, existingOpenTerminalThreadIds, terminalState.terminalOpen]); const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null); - const activeProject = useProjectById(activeThread?.projectId); + const activeProject = useStore((state) => + activeThread?.projectId ? state.projectById[activeThread.projectId] : undefined, + ); const openPullRequestDialog = useCallback( (reference?: string) => { @@ -1595,7 +1622,7 @@ export default function ChatView({ threadId }: ChatViewProps) { const setThreadError = useCallback( (targetThreadId: ThreadId | null, error: string | null) => { if (!targetThreadId) return; - if (useStore.getState().threads.some((thread) => thread.id === targetThreadId)) { + if (useStore.getState().threadShellById[targetThreadId] !== undefined) { setStoreThreadError(targetThreadId, error); return; } diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index dc376a5b3d..0780035e04 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -30,6 +30,7 @@ import { buildPatchCacheKey } from "../lib/diffRendering"; import { resolveDiffThemeName } from "../lib/diffRendering"; import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; import { useStore } from "../store"; +import { createThreadSelector } from "../storeSelectors"; import { useSettings } from "../hooks/useSettings"; import { formatShortTimestamp } from "../timestampFormat"; import { DiffPanelLoadingState, DiffPanelShell, type DiffPanelMode } from "./DiffPanelShell"; @@ -181,12 +182,12 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { const diffSearch = useSearch({ strict: false, select: (search) => parseDiffRouteSearch(search) }); const diffOpen = diffSearch.diff === "1"; const activeThreadId = routeThreadId; - const activeThread = useStore((store) => - activeThreadId ? store.threads.find((thread) => thread.id === activeThreadId) : undefined, + const activeThread = useStore( + useMemo(() => createThreadSelector(activeThreadId), [activeThreadId]), ); const activeProjectId = activeThread?.projectId ?? null; const activeProject = useStore((store) => - activeProjectId ? store.projects.find((project) => project.id === activeProjectId) : undefined, + activeProjectId ? store.projectById[activeProjectId] : undefined, ); const activeCwd = activeThread?.worktreePath ?? activeProject?.cwd; const gitStatusQuery = useQuery(gitStatusQueryOptions(activeCwd ?? null)); diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 42882d000d..85199cf201 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -211,7 +211,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions [activeThreadId], ); const activeServerThread = useStore((store) => - activeThreadId ? store.threads.find((thread) => thread.id === activeThreadId) : undefined, + activeThreadId ? store.threadShellById[activeThreadId] : undefined, ); const setThreadBranch = useStore((store) => store.setThreadBranch); const queryClient = useQueryClient(); diff --git a/apps/web/src/components/KeybindingsToast.browser.tsx b/apps/web/src/components/KeybindingsToast.browser.tsx index b8398e7b8f..68e8935106 100644 --- a/apps/web/src/components/KeybindingsToast.browser.tsx +++ b/apps/web/src/components/KeybindingsToast.browser.tsx @@ -323,8 +323,22 @@ describe("Keybindings update toast", () => { projectDraftThreadIdByProjectId: {}, }); useStore.setState({ - projects: [], - threads: [], + projectIds: [], + projectById: {}, + threadIds: [], + threadIdsByProjectId: {}, + threadShellById: {}, + threadSessionById: {}, + threadTurnStateById: {}, + messageIdsByThreadId: {}, + messageByThreadId: {}, + activityIdsByThreadId: {}, + activityByThreadId: {}, + proposedPlanIdsByThreadId: {}, + proposedPlanByThreadId: {}, + turnDiffIdsByThreadId: {}, + turnDiffSummaryByThreadId: {}, + sidebarThreadSummaryById: {}, bootstrapComplete: false, }); }); diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 89ced46454..68e53cdb3a 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -129,7 +129,6 @@ import { SidebarUpdatePill } from "./sidebar/SidebarUpdatePill"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { useSettings, useUpdateSettings } from "~/hooks/useSettings"; import { useServerKeybindings } from "../rpc/serverState"; -import { useSidebarThreadSummaryById } from "../storeSelectors"; import type { Project } from "../types"; const THREAD_PREVIEW_LIMIT = 6; const SIDEBAR_SORT_LABELS: Record = { @@ -281,7 +280,7 @@ interface SidebarThreadRowProps { } function SidebarThreadRow(props: SidebarThreadRowProps) { - const thread = useSidebarThreadSummaryById(props.threadId); + const thread = useStore((state) => state.sidebarThreadSummaryById[props.threadId]); const lastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[props.threadId]); const runningTerminalIds = useTerminalStateStore( (state) => @@ -666,8 +665,9 @@ function SortableProjectItem({ } export default function Sidebar() { - const projects = useStore((store) => store.projects); - const sidebarThreadsById = useStore((store) => store.sidebarThreadsById); + const projectIds = useStore((store) => store.projectIds); + const projectById = useStore((store) => store.projectById); + const sidebarThreadsById = useStore((store) => store.sidebarThreadSummaryById); const threadIdsByProjectId = useStore((store) => store.threadIdsByProjectId); const { projectExpandedById, projectOrder, threadLastVisitedAtById } = useUiStateStore( useShallow((store) => ({ @@ -729,6 +729,14 @@ export default function Sidebar() { const platform = navigator.platform; const shouldBrowseForProjectImmediately = isElectron && !isLinuxDesktop; const shouldShowProjectPathEntry = addingProject && !shouldBrowseForProjectImmediately; + const projects = useMemo( + () => + projectIds.flatMap((projectId) => { + const project = projectById[projectId]; + return project ? [project] : []; + }), + [projectById, projectIds], + ); const orderedProjects = useMemo(() => { return orderItemsByPreferredIds({ items: projects, diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index e3e620030b..4c258b62e8 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -1414,12 +1414,21 @@ export function GeneralSettingsPanel() { } export function ArchivedThreadsPanel() { - const projects = useStore((store) => store.projects); - const threads = useStore((store) => store.threads); + const projectIds = useStore((store) => store.projectIds); + const projectById = useStore((store) => store.projectById); + const threadIds = useStore((store) => store.threadIds); + const threadShellById = useStore((store) => store.threadShellById); const { unarchiveThread, confirmAndDeleteThread } = useThreadActions(); const archivedGroups = useMemo(() => { - const projectById = new Map(projects.map((project) => [project.id, project] as const)); - return [...projectById.values()] + const projects = projectIds.flatMap((projectId) => { + const project = projectById[projectId]; + return project ? [project] : []; + }); + const threads = threadIds.flatMap((threadId) => { + const thread = threadShellById[threadId]; + return thread ? [thread] : []; + }); + return projects .map((project) => ({ project, threads: threads @@ -1431,7 +1440,7 @@ export function ArchivedThreadsPanel() { }), })) .filter((group) => group.threads.length > 0); - }, [projects, threads]); + }, [projectById, projectIds, threadIds, threadShellById]); const handleArchivedThreadContextMenu = useCallback( async (threadId: ThreadId, position: { x: number; y: number }) => { diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index 1547035bf4..f08b2c7a57 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -10,18 +10,20 @@ import { import { newThreadId } from "../lib/utils"; import { orderItemsByPreferredIds } from "../components/Sidebar.logic"; import { useStore } from "../store"; -import { useThreadById } from "../storeSelectors"; +import { createThreadSelector } from "../storeSelectors"; import { useUiStateStore } from "../uiStateStore"; export function useHandleNewThread() { - const projectIds = useStore(useShallow((store) => store.projects.map((project) => project.id))); + const projectIds = useStore(useShallow((store) => store.projectIds)); 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 activeThread = useStore( + useMemo(() => createThreadSelector(routeThreadId), [routeThreadId]), + ); const activeDraftThread = useComposerDraftStore((store) => routeThreadId ? (store.draftThreadsByThreadId[routeThreadId] ?? null) : null, ); diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index d5557b4a96..bc13b872cd 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -9,7 +9,7 @@ import { useHandleNewThread } from "./useHandleNewThread"; import { gitRemoveWorktreeMutationOptions } from "../lib/gitReactQuery"; import { newCommandId } from "../lib/utils"; import { readNativeApi } from "../nativeApi"; -import { useStore } from "../store"; +import { selectProjectById, selectThreadById, selectThreads, useStore } from "../store"; import { useTerminalStateStore } from "../terminalStateStore"; import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; import { toastManager } from "../components/ui/toast"; @@ -35,7 +35,7 @@ export function useThreadActions() { async (threadId: ThreadId) => { const api = readNativeApi(); if (!api) return; - const thread = useStore.getState().threads.find((entry) => entry.id === threadId); + const thread = selectThreadById(threadId)(useStore.getState()); if (!thread) return; if (thread.session?.status === "running" && thread.session.activeTurnId != null) { throw new Error("Cannot archive a running thread."); @@ -68,10 +68,11 @@ 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); + const state = useStore.getState(); + const threads = selectThreads(state); + const thread = selectThreadById(threadId)(state); if (!thread) return; - const threadProject = projects.find((project) => project.id === thread.projectId); + const threadProject = selectProjectById(thread.projectId)(state); const deletedIds = opts.deletedThreadIds; const survivingThreads = deletedIds && deletedIds.size > 0 @@ -179,7 +180,7 @@ export function useThreadActions() { async (threadId: ThreadId) => { const api = readNativeApi(); if (!api) return; - const thread = useStore.getState().threads.find((entry) => entry.id === threadId); + const thread = selectThreadById(threadId)(useStore.getState()); if (!thread) return; if (appSettings.confirmThreadDelete) { diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 3377e4bb44..04bfd61308 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -33,7 +33,7 @@ import { clearPromotedDraftThreads, useComposerDraftStore, } from "../composerDraftStore"; -import { useStore } from "../store"; +import { selectProjects, selectThreadById, selectThreads, useStore } from "../store"; import { useUiStateStore } from "../uiStateStore"; import { useTerminalStateStore } from "../terminalStateStore"; import { terminalRunningSubprocessFromEvent } from "../terminalActivity"; @@ -314,8 +314,9 @@ function EventRouter() { let flushPendingDomainEventsScheduled = false; const reconcileSnapshotDerivedState = () => { - const threads = useStore.getState().threads; - const projects = useStore.getState().projects; + const storeState = useStore.getState(); + const threads = selectThreads(storeState); + const projects = selectProjects(storeState); syncProjects(projects.map((project) => ({ id: project.id, cwd: project.cwd }))); syncThreads( threads.map((thread) => ({ @@ -378,14 +379,14 @@ function EventRouter() { applyOrchestrationEvents(uiEvents); if (needsProjectUiSync) { - const projects = useStore.getState().projects; + const projects = selectProjects(useStore.getState()); 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; + const threads = selectThreads(useStore.getState()); syncThreads( threads.map((thread) => ({ id: thread.id, @@ -501,7 +502,7 @@ function EventRouter() { } }); const unsubTerminalEvent = api.terminal.onEvent((event) => { - const thread = useStore.getState().threads.find((entry) => entry.id === event.threadId); + const thread = selectThreadById(ThreadId.makeUnsafe(event.threadId))(useStore.getState()); if (thread && thread.archivedAt !== null) { return; } diff --git a/apps/web/src/routes/_chat.$threadId.tsx b/apps/web/src/routes/_chat.$threadId.tsx index 31920cf40f..99ecc05e7d 100644 --- a/apps/web/src/routes/_chat.$threadId.tsx +++ b/apps/web/src/routes/_chat.$threadId.tsx @@ -167,7 +167,7 @@ function ChatThreadRouteView() { select: (params) => ThreadId.makeUnsafe(params.threadId), }); const search = Route.useSearch(); - const threadExists = useStore((store) => store.threads.some((thread) => thread.id === threadId)); + const threadExists = useStore((store) => store.threadShellById[threadId] !== undefined); const draftThreadExists = useComposerDraftStore((store) => Object.hasOwn(store.draftThreadsByThreadId, threadId), ); diff --git a/apps/web/src/store.test.ts b/apps/web/src/store.test.ts index da1498d494..2294674848 100644 --- a/apps/web/src/store.test.ts +++ b/apps/web/src/store.test.ts @@ -14,6 +14,8 @@ import { describe, expect, it } from "vitest"; import { applyOrchestrationEvent, applyOrchestrationEvents, + selectProjects, + selectThreads, syncServerReadModel, type AppState, } from "./store"; @@ -47,29 +49,125 @@ function makeThread(overrides: Partial = {}): Thread { } function makeState(thread: Thread): AppState { + const projectId = ProjectId.makeUnsafe("project-1"); + const project = { + id: projectId, + name: "Project", + cwd: "/tmp/project", + defaultModelSelection: { + provider: "codex" as const, + model: "gpt-5-codex", + }, + createdAt: "2026-02-13T00:00:00.000Z", + updatedAt: "2026-02-13T00:00:00.000Z", + scripts: [], + }; const threadIdsByProjectId: AppState["threadIdsByProjectId"] = { [thread.projectId]: [thread.id], }; return { - projects: [ - { - id: ProjectId.makeUnsafe("project-1"), - name: "Project", - cwd: "/tmp/project", - defaultModelSelection: { - provider: "codex", - model: "gpt-5-codex", - }, - scripts: [], - }, - ], - threads: [thread], - sidebarThreadsById: {}, + projectIds: [projectId], + projectById: { + [projectId]: project, + }, + threadIds: [thread.id], threadIdsByProjectId, + threadShellById: { + [thread.id]: { + id: thread.id, + codexThreadId: thread.codexThreadId, + projectId: thread.projectId, + title: thread.title, + modelSelection: thread.modelSelection, + runtimeMode: thread.runtimeMode, + interactionMode: thread.interactionMode, + error: thread.error, + createdAt: thread.createdAt, + archivedAt: thread.archivedAt, + updatedAt: thread.updatedAt, + branch: thread.branch, + worktreePath: thread.worktreePath, + }, + }, + threadSessionById: { + [thread.id]: thread.session, + }, + threadTurnStateById: { + [thread.id]: { + latestTurn: thread.latestTurn, + ...(thread.pendingSourceProposedPlan + ? { pendingSourceProposedPlan: thread.pendingSourceProposedPlan } + : {}), + }, + }, + messageIdsByThreadId: { + [thread.id]: thread.messages.map((message) => message.id), + }, + messageByThreadId: { + [thread.id]: Object.fromEntries( + thread.messages.map((message) => [message.id, message] as const), + ) as AppState["messageByThreadId"][ThreadId], + }, + activityIdsByThreadId: { + [thread.id]: thread.activities.map((activity) => activity.id), + }, + activityByThreadId: { + [thread.id]: Object.fromEntries( + thread.activities.map((activity) => [activity.id, activity] as const), + ) as AppState["activityByThreadId"][ThreadId], + }, + proposedPlanIdsByThreadId: { + [thread.id]: thread.proposedPlans.map((plan) => plan.id), + }, + proposedPlanByThreadId: { + [thread.id]: Object.fromEntries( + thread.proposedPlans.map((plan) => [plan.id, plan] as const), + ) as AppState["proposedPlanByThreadId"][ThreadId], + }, + turnDiffIdsByThreadId: { + [thread.id]: thread.turnDiffSummaries.map((summary) => summary.turnId), + }, + turnDiffSummaryByThreadId: { + [thread.id]: Object.fromEntries( + thread.turnDiffSummaries.map((summary) => [summary.turnId, summary] as const), + ) as AppState["turnDiffSummaryByThreadId"][ThreadId], + }, + sidebarThreadSummaryById: {}, + bootstrapComplete: true, + }; +} + +function makeEmptyState(overrides: Partial = {}): AppState { + return { + projectIds: [], + projectById: {}, + threadIds: [], + threadIdsByProjectId: {}, + threadShellById: {}, + threadSessionById: {}, + threadTurnStateById: {}, + messageIdsByThreadId: {}, + messageByThreadId: {}, + activityIdsByThreadId: {}, + activityByThreadId: {}, + proposedPlanIdsByThreadId: {}, + proposedPlanByThreadId: {}, + turnDiffIdsByThreadId: {}, + turnDiffSummaryByThreadId: {}, + sidebarThreadSummaryById: {}, bootstrapComplete: true, + ...overrides, }; } +function projectsOf(state: AppState) { + return selectProjects(state); +} + +function threadsOf(state: AppState) { + return selectThreads(state); +} + function makeEvent( type: T, payload: Extract["payload"], @@ -191,7 +289,7 @@ describe("store read model sync", () => { const next = syncServerReadModel(initialState, readModel); - expect(next.threads[0]?.modelSelection.model).toBe("claude-opus-4-6"); + expect(threadsOf(next)[0]?.modelSelection.model).toBe("claude-opus-4-6"); }); it("resolves claude aliases when session provider is claudeAgent", () => { @@ -216,7 +314,7 @@ describe("store read model sync", () => { const next = syncServerReadModel(initialState, readModel); - expect(next.threads[0]?.modelSelection.model).toBe("claude-sonnet-4-6"); + expect(threadsOf(next)[0]?.modelSelection.model).toBe("claude-sonnet-4-6"); }); it("preserves project and thread updatedAt timestamps from the read model", () => { @@ -229,8 +327,8 @@ describe("store read model sync", () => { const next = syncServerReadModel(initialState, readModel); - expect(next.projects[0]?.updatedAt).toBe("2026-02-27T00:00:00.000Z"); - expect(next.threads[0]?.updatedAt).toBe("2026-02-27T00:05:00.000Z"); + expect(projectsOf(next)[0]?.updatedAt).toBe("2026-02-27T00:00:00.000Z"); + expect(threadsOf(next)[0]?.updatedAt).toBe("2026-02-27T00:05:00.000Z"); }); it("maps archivedAt from the read model", () => { @@ -245,16 +343,17 @@ describe("store read model sync", () => { ), ); - expect(next.threads[0]?.archivedAt).toBe(archivedAt); + expect(threadsOf(next)[0]?.archivedAt).toBe(archivedAt); }); 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"); - const initialState: AppState = { - projects: [ - { + const initialState: AppState = makeEmptyState({ + projectIds: [project2, project1], + projectById: { + [project2]: { id: project2, name: "Project 2", cwd: "/tmp/project-2", @@ -262,9 +361,11 @@ describe("store read model sync", () => { provider: "codex", model: DEFAULT_MODEL_BY_PROVIDER.codex, }, + createdAt: "2026-02-27T00:00:00.000Z", + updatedAt: "2026-02-27T00:00:00.000Z", scripts: [], }, - { + [project1]: { id: project1, name: "Project 1", cwd: "/tmp/project-1", @@ -272,14 +373,12 @@ describe("store read model sync", () => { provider: "codex", model: DEFAULT_MODEL_BY_PROVIDER.codex, }, + createdAt: "2026-02-27T00:00:00.000Z", + updatedAt: "2026-02-27T00:00:00.000Z", scripts: [], }, - ], - threads: [], - sidebarThreadsById: {}, - threadIdsByProjectId: {}, - bootstrapComplete: true, - }; + }, + }); const readModel: OrchestrationReadModel = { snapshotSequence: 2, updatedAt: "2026-02-27T00:00:00.000Z", @@ -305,7 +404,7 @@ describe("store read model sync", () => { const next = syncServerReadModel(initialState, readModel); - expect(next.projects.map((project) => project.id)).toEqual([project1, project2, project3]); + expect(projectsOf(next).map((project) => project.id)).toEqual([project1, project2, project3]); }); }); @@ -354,9 +453,10 @@ describe("incremental orchestration updates", () => { 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: [ - { + const state: AppState = makeEmptyState({ + projectIds: [originalProjectId], + projectById: { + [originalProjectId]: { id: originalProjectId, name: "Project", cwd: "/tmp/project", @@ -364,14 +464,12 @@ describe("incremental orchestration updates", () => { provider: "codex", model: DEFAULT_MODEL_BY_PROVIDER.codex, }, + createdAt: "2026-02-27T00:00:00.000Z", + updatedAt: "2026-02-27T00:00:00.000Z", scripts: [], }, - ], - threads: [], - sidebarThreadsById: {}, - threadIdsByProjectId: {}, - bootstrapComplete: true, - }; + }, + }); const next = applyOrchestrationEvent( state, @@ -389,10 +487,10 @@ describe("incremental orchestration updates", () => { }), ); - 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"); + expect(projectsOf(next)).toHaveLength(1); + expect(projectsOf(next)[0]?.id).toBe(recreatedProjectId); + expect(projectsOf(next)[0]?.cwd).toBe("/tmp/project"); + expect(projectsOf(next)[0]?.name).toBe("Project Recreated"); }); it("removes stale project index entries when thread.created recreates a thread under a new project", () => { @@ -404,8 +502,10 @@ describe("incremental orchestration updates", () => { projectId: originalProjectId, }); const state: AppState = { - projects: [ - { + ...makeState(thread), + projectIds: [originalProjectId, recreatedProjectId], + projectById: { + [originalProjectId]: { id: originalProjectId, name: "Project 1", cwd: "/tmp/project-1", @@ -413,9 +513,11 @@ describe("incremental orchestration updates", () => { provider: "codex", model: DEFAULT_MODEL_BY_PROVIDER.codex, }, + createdAt: "2026-02-27T00:00:00.000Z", + updatedAt: "2026-02-27T00:00:00.000Z", scripts: [], }, - { + [recreatedProjectId]: { id: recreatedProjectId, name: "Project 2", cwd: "/tmp/project-2", @@ -423,15 +525,11 @@ describe("incremental orchestration updates", () => { provider: "codex", model: DEFAULT_MODEL_BY_PROVIDER.codex, }, + createdAt: "2026-02-27T00:00:00.000Z", + updatedAt: "2026-02-27T00:00:00.000Z", scripts: [], }, - ], - threads: [thread], - sidebarThreadsById: {}, - threadIdsByProjectId: { - [originalProjectId]: [threadId], }, - bootstrapComplete: true, }; const next = applyOrchestrationEvent( @@ -453,8 +551,8 @@ describe("incremental orchestration updates", () => { }), ); - expect(next.threads).toHaveLength(1); - expect(next.threads[0]?.projectId).toBe(recreatedProjectId); + expect(threadsOf(next)).toHaveLength(1); + expect(threadsOf(next)[0]?.projectId).toBe(recreatedProjectId); expect(next.threadIdsByProjectId[originalProjectId]).toBeUndefined(); expect(next.threadIdsByProjectId[recreatedProjectId]).toEqual([threadId]); }); @@ -477,7 +575,73 @@ describe("incremental orchestration updates", () => { const thread2 = makeThread({ id: ThreadId.makeUnsafe("thread-2") }); const state: AppState = { ...makeState(thread1), - threads: [thread1, thread2], + threadIds: [thread1.id, thread2.id], + threadShellById: { + ...makeState(thread1).threadShellById, + [thread2.id]: { + id: thread2.id, + codexThreadId: thread2.codexThreadId, + projectId: thread2.projectId, + title: thread2.title, + modelSelection: thread2.modelSelection, + runtimeMode: thread2.runtimeMode, + interactionMode: thread2.interactionMode, + error: thread2.error, + createdAt: thread2.createdAt, + archivedAt: thread2.archivedAt, + updatedAt: thread2.updatedAt, + branch: thread2.branch, + worktreePath: thread2.worktreePath, + }, + }, + threadSessionById: { + ...makeState(thread1).threadSessionById, + [thread2.id]: thread2.session, + }, + threadTurnStateById: { + ...makeState(thread1).threadTurnStateById, + [thread2.id]: { + latestTurn: thread2.latestTurn, + }, + }, + messageIdsByThreadId: { + ...makeState(thread1).messageIdsByThreadId, + [thread2.id]: [], + }, + messageByThreadId: { + ...makeState(thread1).messageByThreadId, + [thread2.id]: {}, + }, + activityIdsByThreadId: { + ...makeState(thread1).activityIdsByThreadId, + [thread2.id]: [], + }, + activityByThreadId: { + ...makeState(thread1).activityByThreadId, + [thread2.id]: {}, + }, + proposedPlanIdsByThreadId: { + ...makeState(thread1).proposedPlanIdsByThreadId, + [thread2.id]: [], + }, + proposedPlanByThreadId: { + ...makeState(thread1).proposedPlanByThreadId, + [thread2.id]: {}, + }, + turnDiffIdsByThreadId: { + ...makeState(thread1).turnDiffIdsByThreadId, + [thread2.id]: [], + }, + turnDiffSummaryByThreadId: { + ...makeState(thread1).turnDiffSummaryByThreadId, + [thread2.id]: {}, + }, + sidebarThreadSummaryById: { + ...makeState(thread1).sidebarThreadSummaryById, + }, + threadIdsByProjectId: { + [thread1.projectId]: [thread1.id, thread2.id], + }, }; const next = applyOrchestrationEvent( @@ -494,9 +658,9 @@ describe("incremental orchestration updates", () => { }), ); - expect(next.threads[0]?.messages[0]?.text).toBe("hello world"); - expect(next.threads[0]?.latestTurn?.state).toBe("running"); - expect(next.threads[1]).toBe(thread2); + expect(threadsOf(next)[0]?.messages[0]?.text).toBe("hello world"); + expect(threadsOf(next)[0]?.latestTurn?.state).toBe("running"); + expect(threadsOf(next)[1]).toBe(thread2); }); it("applies replay batches in sequence and updates session state", () => { @@ -545,9 +709,9 @@ describe("incremental orchestration updates", () => { ), ]); - expect(next.threads[0]?.session?.status).toBe("running"); - expect(next.threads[0]?.latestTurn?.state).toBe("completed"); - expect(next.threads[0]?.messages).toHaveLength(1); + expect(threadsOf(next)[0]?.session?.status).toBe("running"); + expect(threadsOf(next)[0]?.latestTurn?.state).toBe("completed"); + expect(threadsOf(next)[0]?.messages).toHaveLength(1); }); it("does not regress latestTurn when an older turn diff completes late", () => { @@ -578,8 +742,8 @@ describe("incremental orchestration updates", () => { }), ); - expect(next.threads[0]?.turnDiffSummaries).toHaveLength(1); - expect(next.threads[0]?.latestTurn).toEqual(state.threads[0]?.latestTurn); + expect(threadsOf(next)[0]?.turnDiffSummaries).toHaveLength(1); + expect(threadsOf(next)[0]?.latestTurn).toEqual(threadsOf(state)[0]?.latestTurn); }); it("rebinds live turn diffs to the authoritative assistant message when it arrives later", () => { @@ -622,10 +786,10 @@ describe("incremental orchestration updates", () => { }), ); - expect(next.threads[0]?.turnDiffSummaries[0]?.assistantMessageId).toBe( + expect(threadsOf(next)[0]?.turnDiffSummaries[0]?.assistantMessageId).toBe( MessageId.makeUnsafe("assistant-real"), ); - expect(next.threads[0]?.latestTurn?.assistantMessageId).toBe( + expect(threadsOf(next)[0]?.latestTurn?.assistantMessageId).toBe( MessageId.makeUnsafe("assistant-real"), ); }); @@ -731,15 +895,15 @@ describe("incremental orchestration updates", () => { }), ); - expect(next.threads[0]?.messages.map((message) => message.id)).toEqual([ + expect(threadsOf(next)[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([ + expect(threadsOf(next)[0]?.proposedPlans.map((plan) => plan.id)).toEqual(["plan-1"]); + expect(threadsOf(next)[0]?.activities.map((activity) => activity.id)).toEqual([ EventId.makeUnsafe("activity-1"), ]); - expect(next.threads[0]?.turnDiffSummaries.map((summary) => summary.turnId)).toEqual([ + expect(threadsOf(next)[0]?.turnDiffSummaries.map((summary) => summary.turnId)).toEqual([ TurnId.makeUnsafe("turn-1"), ]); }); @@ -789,7 +953,7 @@ describe("incremental orchestration updates", () => { }), ); - expect(reverted.threads[0]?.pendingSourceProposedPlan).toBeUndefined(); + expect(threadsOf(reverted)[0]?.pendingSourceProposedPlan).toBeUndefined(); const next = applyOrchestrationEvent( reverted, @@ -807,10 +971,10 @@ describe("incremental orchestration updates", () => { }), ); - expect(next.threads[0]?.latestTurn).toMatchObject({ + expect(threadsOf(next)[0]?.latestTurn).toMatchObject({ turnId: TurnId.makeUnsafe("turn-3"), state: "running", }); - expect(next.threads[0]?.latestTurn?.sourceProposedPlan).toBeUndefined(); + expect(threadsOf(next)[0]?.latestTurn?.sourceProposedPlan).toBeUndefined(); }); }); diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index 12c709b796..318d92173c 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -1,83 +1,100 @@ import { + type MessageId, + type OrchestrationCheckpointSummary, type OrchestrationEvent, type OrchestrationMessage, type OrchestrationProposedPlan, - type ProjectId, - type ProviderKind, - ThreadId, type OrchestrationReadModel, type OrchestrationSession, - type OrchestrationCheckpointSummary, - type OrchestrationThread, type OrchestrationSessionStatus, + type OrchestrationThread, + type OrchestrationThreadActivity, + type ProjectId, + type ProviderKind, + ThreadId, + type TurnId, } from "@t3tools/contracts"; import { resolveModelSlugForProvider } from "@t3tools/shared/model"; import { create } from "zustand"; import { - findLatestProposedPlan, - hasActionableProposedPlan, derivePendingApprovals, derivePendingUserInputs, + findLatestProposedPlan, + hasActionableProposedPlan, } from "./session-logic"; -import { type ChatMessage, type Project, type SidebarThreadSummary, type Thread } from "./types"; - -// ── State ──────────────────────────────────────────────────────────── +import { + type ChatMessage, + type Project, + type ProposedPlan, + type SidebarThreadSummary, + type Thread, + type ThreadSession, + type ThreadShell, + type ThreadTurnState, + type TurnDiffSummary, +} from "./types"; export interface AppState { - projects: Project[]; - threads: Thread[]; - sidebarThreadsById: Record; - threadIdsByProjectId: Record; + projectIds: ProjectId[]; + projectById: Record; + threadIds: ThreadId[]; + threadIdsByProjectId: Record; + threadShellById: Record; + threadSessionById: Record; + threadTurnStateById: Record; + messageIdsByThreadId: Record; + messageByThreadId: Record>; + activityIdsByThreadId: Record; + activityByThreadId: Record>; + proposedPlanIdsByThreadId: Record; + proposedPlanByThreadId: Record>; + turnDiffIdsByThreadId: Record; + turnDiffSummaryByThreadId: Record>; + sidebarThreadSummaryById: Record; bootstrapComplete: boolean; } const initialState: AppState = { - projects: [], - threads: [], - sidebarThreadsById: {}, + projectIds: [], + projectById: {}, + threadIds: [], threadIdsByProjectId: {}, + threadShellById: {}, + threadSessionById: {}, + threadTurnStateById: {}, + messageIdsByThreadId: {}, + messageByThreadId: {}, + activityIdsByThreadId: {}, + activityByThreadId: {}, + proposedPlanIdsByThreadId: {}, + proposedPlanByThreadId: {}, + turnDiffIdsByThreadId: {}, + turnDiffSummaryByThreadId: {}, + sidebarThreadSummaryById: {}, bootstrapComplete: false, }; + const MAX_THREAD_MESSAGES = 2_000; const MAX_THREAD_CHECKPOINTS = 500; const MAX_THREAD_PROPOSED_PLANS = 200; const MAX_THREAD_ACTIVITIES = 500; const EMPTY_THREAD_IDS: ThreadId[] = []; - -// ── Pure helpers ────────────────────────────────────────────────────── - -function updateThread( - threads: Thread[], - threadId: ThreadId, - updater: (t: Thread) => Thread, -): Thread[] { - let changed = false; - const next = threads.map((t) => { - if (t.id !== threadId) return t; - const updated = updater(t); - if (updated !== t) changed = true; - return updated; - }); - return changed ? next : threads; -} - -function updateProject( - projects: Project[], - projectId: Project["id"], - updater: (project: Project) => Project, -): Project[] { - let changed = false; - const next = projects.map((project) => { - if (project.id !== projectId) { - return project; - } - const updated = updater(project); - if (updated !== project) { - changed = true; - } - return updated; - }); - return changed ? next : projects; +const EMPTY_MESSAGE_IDS: MessageId[] = []; +const EMPTY_ACTIVITY_IDS: string[] = []; +const EMPTY_PROPOSED_PLAN_IDS: string[] = []; +const EMPTY_TURN_IDS: TurnId[] = []; +const EMPTY_MESSAGES: ChatMessage[] = []; +const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = []; +const EMPTY_PROPOSED_PLANS: ProposedPlan[] = []; +const EMPTY_TURN_DIFF_SUMMARIES: TurnDiffSummary[] = []; +const EMPTY_MESSAGE_MAP: Record = {}; +const EMPTY_ACTIVITY_MAP: Record = {}; +const EMPTY_PROPOSED_PLAN_MAP: Record = {}; +const EMPTY_TURN_DIFF_MAP: Record = {}; +const EMPTY_THREAD_TURN_STATE: ThreadTurnState = Object.freeze({ latestTurn: null }); + +function arraysEqual(left: readonly T[], right: readonly T[]): boolean { + return left.length === right.length && left.every((value, index) => value === right[index]); } function normalizeModelSelection( @@ -93,7 +110,7 @@ function mapProjectScripts(scripts: ReadonlyArray): return scripts.map((script) => ({ ...script })); } -function mapSession(session: OrchestrationSession): Thread["session"] { +function mapSession(session: OrchestrationSession): ThreadSession { return { provider: toLegacyProvider(session.providerName), status: toLegacySessionStatus(session.status), @@ -127,7 +144,7 @@ function mapMessage(message: OrchestrationMessage): ChatMessage { }; } -function mapProposedPlan(proposedPlan: OrchestrationProposedPlan): Thread["proposedPlans"][number] { +function mapProposedPlan(proposedPlan: OrchestrationProposedPlan): ProposedPlan { return { id: proposedPlan.id, turnId: proposedPlan.turnId, @@ -139,9 +156,7 @@ function mapProposedPlan(proposedPlan: OrchestrationProposedPlan): Thread["propo }; } -function mapTurnDiffSummary( - checkpoint: OrchestrationCheckpointSummary, -): Thread["turnDiffSummaries"][number] { +function mapTurnDiffSummary(checkpoint: OrchestrationCheckpointSummary): TurnDiffSummary { return { turnId: checkpoint.turnId, completedAt: checkpoint.completedAt, @@ -153,6 +168,20 @@ function mapTurnDiffSummary( }; } +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 mapThread(thread: OrchestrationThread): Thread { return { id: thread.id, @@ -178,25 +207,35 @@ function mapThread(thread: OrchestrationThread): Thread { }; } -function mapProject(project: OrchestrationReadModel["projects"][number]): Project { +function toThreadShell(thread: Thread): ThreadShell { 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), + id: thread.id, + codexThreadId: thread.codexThreadId, + projectId: thread.projectId, + title: thread.title, + modelSelection: thread.modelSelection, + runtimeMode: thread.runtimeMode, + interactionMode: thread.interactionMode, + error: thread.error, + createdAt: thread.createdAt, + archivedAt: thread.archivedAt, + updatedAt: thread.updatedAt, + branch: thread.branch, + worktreePath: thread.worktreePath, }; } -function getLatestUserMessageAt( - messages: ReadonlyArray, -): string | null { - let latestUserMessageAt: string | null = null; +function toThreadTurnState(thread: Thread): ThreadTurnState { + return { + latestTurn: thread.latestTurn, + ...(thread.pendingSourceProposedPlan + ? { pendingSourceProposedPlan: thread.pendingSourceProposedPlan } + : {}), + }; +} +function getLatestUserMessageAt(messages: ReadonlyArray): string | null { + let latestUserMessageAt: string | null = null; for (const message of messages) { if (message.role !== "user") { continue; @@ -205,7 +244,6 @@ function getLatestUserMessageAt( latestUserMessageAt = message.createdAt; } } - return latestUserMessageAt; } @@ -255,62 +293,378 @@ function sidebarThreadSummariesEqual( ); } -function appendThreadIdByProjectId( - threadIdsByProjectId: Record, - projectId: ProjectId, - threadId: ThreadId, -): Record { - const existingThreadIds = threadIdsByProjectId[projectId] ?? EMPTY_THREAD_IDS; - if (existingThreadIds.includes(threadId)) { - return threadIdsByProjectId; - } +function threadShellsEqual(left: ThreadShell | undefined, right: ThreadShell): boolean { + return ( + left !== undefined && + left.id === right.id && + left.codexThreadId === right.codexThreadId && + left.projectId === right.projectId && + left.title === right.title && + left.modelSelection === right.modelSelection && + left.runtimeMode === right.runtimeMode && + left.interactionMode === right.interactionMode && + left.error === right.error && + left.createdAt === right.createdAt && + left.archivedAt === right.archivedAt && + left.updatedAt === right.updatedAt && + left.branch === right.branch && + left.worktreePath === right.worktreePath + ); +} + +function threadTurnStatesEqual(left: ThreadTurnState | undefined, right: ThreadTurnState): boolean { + return ( + left !== undefined && + left.latestTurn === right.latestTurn && + left.pendingSourceProposedPlan === right.pendingSourceProposedPlan + ); +} + +function appendId(ids: readonly T[], id: T): T[] { + return ids.includes(id) ? [...ids] : [...ids, id]; +} + +function removeId(ids: readonly T[], id: T): T[] { + return ids.filter((value) => value !== id); +} + +function buildMessageSlice(thread: Thread): { + ids: MessageId[]; + byId: Record; +} { + return { + ids: thread.messages.map((message) => message.id), + byId: Object.fromEntries( + thread.messages.map((message) => [message.id, message] as const), + ) as Record, + }; +} + +function buildActivitySlice(thread: Thread): { + ids: string[]; + byId: Record; +} { return { - ...threadIdsByProjectId, - [projectId]: [...existingThreadIds, threadId], + ids: thread.activities.map((activity) => activity.id), + byId: Object.fromEntries( + thread.activities.map((activity) => [activity.id, activity] as const), + ) as Record, }; } -function removeThreadIdByProjectId( - threadIdsByProjectId: Record, - projectId: ProjectId, +function buildProposedPlanSlice(thread: Thread): { + ids: string[]; + byId: Record; +} { + return { + ids: thread.proposedPlans.map((plan) => plan.id), + byId: Object.fromEntries( + thread.proposedPlans.map((plan) => [plan.id, plan] as const), + ) as Record, + }; +} + +function buildTurnDiffSlice(thread: Thread): { + ids: TurnId[]; + byId: Record; +} { + return { + ids: thread.turnDiffSummaries.map((summary) => summary.turnId), + byId: Object.fromEntries( + thread.turnDiffSummaries.map((summary) => [summary.turnId, summary] as const), + ) as Record, + }; +} + +function selectThreadMessages(state: AppState, threadId: ThreadId): ChatMessage[] { + const ids = state.messageIdsByThreadId[threadId] ?? EMPTY_MESSAGE_IDS; + const byId = state.messageByThreadId[threadId] ?? EMPTY_MESSAGE_MAP; + if (ids.length === 0) { + return EMPTY_MESSAGES; + } + return ids.flatMap((id) => { + const message = byId[id]; + return message ? [message] : []; + }); +} + +function selectThreadActivities( + state: AppState, threadId: ThreadId, -): Record { - const existingThreadIds = threadIdsByProjectId[projectId] ?? EMPTY_THREAD_IDS; - if (!existingThreadIds.includes(threadId)) { - return threadIdsByProjectId; +): OrchestrationThreadActivity[] { + const ids = state.activityIdsByThreadId[threadId] ?? EMPTY_ACTIVITY_IDS; + const byId = state.activityByThreadId[threadId] ?? EMPTY_ACTIVITY_MAP; + if (ids.length === 0) { + return EMPTY_ACTIVITIES; } - const nextThreadIds = existingThreadIds.filter( - (existingThreadId) => existingThreadId !== threadId, - ); - if (nextThreadIds.length === existingThreadIds.length) { - return threadIdsByProjectId; + return ids.flatMap((id) => { + const activity = byId[id]; + return activity ? [activity] : []; + }); +} + +function selectThreadProposedPlans(state: AppState, threadId: ThreadId): ProposedPlan[] { + const ids = state.proposedPlanIdsByThreadId[threadId] ?? EMPTY_PROPOSED_PLAN_IDS; + const byId = state.proposedPlanByThreadId[threadId] ?? EMPTY_PROPOSED_PLAN_MAP; + if (ids.length === 0) { + return EMPTY_PROPOSED_PLANS; } - if (nextThreadIds.length === 0) { - const nextThreadIdsByProjectId = { ...threadIdsByProjectId }; - delete nextThreadIdsByProjectId[projectId]; - return nextThreadIdsByProjectId; + return ids.flatMap((id) => { + const plan = byId[id]; + return plan ? [plan] : []; + }); +} + +function selectThreadTurnDiffSummaries(state: AppState, threadId: ThreadId): TurnDiffSummary[] { + const ids = state.turnDiffIdsByThreadId[threadId] ?? EMPTY_TURN_IDS; + const byId = state.turnDiffSummaryByThreadId[threadId] ?? EMPTY_TURN_DIFF_MAP; + if (ids.length === 0) { + return EMPTY_TURN_DIFF_SUMMARIES; + } + return ids.flatMap((id) => { + const summary = byId[id]; + return summary ? [summary] : []; + }); +} + +function getThread(state: AppState, threadId: ThreadId): Thread | undefined { + const shell = state.threadShellById[threadId]; + if (!shell) { + return undefined; } + const turnState = state.threadTurnStateById[threadId] ?? EMPTY_THREAD_TURN_STATE; return { - ...threadIdsByProjectId, - [projectId]: nextThreadIds, + ...shell, + session: state.threadSessionById[threadId] ?? null, + latestTurn: turnState.latestTurn, + pendingSourceProposedPlan: turnState.pendingSourceProposedPlan, + messages: selectThreadMessages(state, threadId), + activities: selectThreadActivities(state, threadId), + proposedPlans: selectThreadProposedPlans(state, threadId), + turnDiffSummaries: selectThreadTurnDiffSummaries(state, threadId), }; } -function buildThreadIdsByProjectId(threads: ReadonlyArray): Record { - const threadIdsByProjectId: Record = {}; - for (const thread of threads) { - const existingThreadIds = threadIdsByProjectId[thread.projectId] ?? EMPTY_THREAD_IDS; - threadIdsByProjectId[thread.projectId] = [...existingThreadIds, thread.id]; +function getProjects(state: AppState): Project[] { + return state.projectIds.flatMap((projectId) => { + const project = state.projectById[projectId]; + return project ? [project] : []; + }); +} + +function getThreads(state: AppState): Thread[] { + return state.threadIds.flatMap((threadId) => { + const thread = getThread(state, threadId); + return thread ? [thread] : []; + }); +} + +function writeThreadState(state: AppState, nextThread: Thread, previousThread?: Thread): AppState { + const nextShell = toThreadShell(nextThread); + const nextTurnState = toThreadTurnState(nextThread); + const previousShell = state.threadShellById[nextThread.id]; + const previousTurnState = state.threadTurnStateById[nextThread.id]; + const previousSummary = state.sidebarThreadSummaryById[nextThread.id]; + const nextSummary = buildSidebarThreadSummary(nextThread); + + let nextState = state; + + if (!state.threadIds.includes(nextThread.id)) { + nextState = { + ...nextState, + threadIds: [...nextState.threadIds, nextThread.id], + }; } - return threadIdsByProjectId; + + const previousProjectId = previousThread?.projectId; + const nextProjectId = nextThread.projectId; + if (previousProjectId !== nextProjectId) { + let threadIdsByProjectId = nextState.threadIdsByProjectId; + if (previousProjectId) { + const previousIds = threadIdsByProjectId[previousProjectId] ?? EMPTY_THREAD_IDS; + const nextIds = removeId(previousIds, nextThread.id); + if (nextIds.length === 0) { + const { [previousProjectId]: _removed, ...rest } = threadIdsByProjectId; + threadIdsByProjectId = rest as Record; + } else if (!arraysEqual(previousIds, nextIds)) { + threadIdsByProjectId = { + ...threadIdsByProjectId, + [previousProjectId]: nextIds, + }; + } + } + const projectThreadIds = threadIdsByProjectId[nextProjectId] ?? EMPTY_THREAD_IDS; + const nextProjectThreadIds = appendId(projectThreadIds, nextThread.id); + if (!arraysEqual(projectThreadIds, nextProjectThreadIds)) { + threadIdsByProjectId = { + ...threadIdsByProjectId, + [nextProjectId]: nextProjectThreadIds, + }; + } + if (threadIdsByProjectId !== nextState.threadIdsByProjectId) { + nextState = { + ...nextState, + threadIdsByProjectId, + }; + } + } + + if (!threadShellsEqual(previousShell, nextShell)) { + nextState = { + ...nextState, + threadShellById: { + ...nextState.threadShellById, + [nextThread.id]: nextShell, + }, + }; + } + + if ((previousThread?.session ?? null) !== nextThread.session) { + nextState = { + ...nextState, + threadSessionById: { + ...nextState.threadSessionById, + [nextThread.id]: nextThread.session, + }, + }; + } + + if (!threadTurnStatesEqual(previousTurnState, nextTurnState)) { + nextState = { + ...nextState, + threadTurnStateById: { + ...nextState.threadTurnStateById, + [nextThread.id]: nextTurnState, + }, + }; + } + + if (previousThread?.messages !== nextThread.messages) { + const nextMessageSlice = buildMessageSlice(nextThread); + nextState = { + ...nextState, + messageIdsByThreadId: { + ...nextState.messageIdsByThreadId, + [nextThread.id]: nextMessageSlice.ids, + }, + messageByThreadId: { + ...nextState.messageByThreadId, + [nextThread.id]: nextMessageSlice.byId, + }, + }; + } + + if (previousThread?.activities !== nextThread.activities) { + const nextActivitySlice = buildActivitySlice(nextThread); + nextState = { + ...nextState, + activityIdsByThreadId: { + ...nextState.activityIdsByThreadId, + [nextThread.id]: nextActivitySlice.ids, + }, + activityByThreadId: { + ...nextState.activityByThreadId, + [nextThread.id]: nextActivitySlice.byId, + }, + }; + } + + if (previousThread?.proposedPlans !== nextThread.proposedPlans) { + const nextProposedPlanSlice = buildProposedPlanSlice(nextThread); + nextState = { + ...nextState, + proposedPlanIdsByThreadId: { + ...nextState.proposedPlanIdsByThreadId, + [nextThread.id]: nextProposedPlanSlice.ids, + }, + proposedPlanByThreadId: { + ...nextState.proposedPlanByThreadId, + [nextThread.id]: nextProposedPlanSlice.byId, + }, + }; + } + + if (previousThread?.turnDiffSummaries !== nextThread.turnDiffSummaries) { + const nextTurnDiffSlice = buildTurnDiffSlice(nextThread); + nextState = { + ...nextState, + turnDiffIdsByThreadId: { + ...nextState.turnDiffIdsByThreadId, + [nextThread.id]: nextTurnDiffSlice.ids, + }, + turnDiffSummaryByThreadId: { + ...nextState.turnDiffSummaryByThreadId, + [nextThread.id]: nextTurnDiffSlice.byId, + }, + }; + } + + if (!sidebarThreadSummariesEqual(previousSummary, nextSummary)) { + nextState = { + ...nextState, + sidebarThreadSummaryById: { + ...nextState.sidebarThreadSummaryById, + [nextThread.id]: nextSummary, + }, + }; + } + + return nextState; } -function buildSidebarThreadsById( - threads: ReadonlyArray, -): Record { - return Object.fromEntries( - threads.map((thread) => [thread.id, buildSidebarThreadSummary(thread)]), - ); +function removeThreadState(state: AppState, threadId: ThreadId): AppState { + const shell = state.threadShellById[threadId]; + if (!shell) { + return state; + } + + const nextThreadIds = removeId(state.threadIds, threadId); + const currentProjectThreadIds = state.threadIdsByProjectId[shell.projectId] ?? EMPTY_THREAD_IDS; + const nextProjectThreadIds = removeId(currentProjectThreadIds, threadId); + const nextThreadIdsByProjectId = + nextProjectThreadIds.length === 0 + ? (() => { + const { [shell.projectId]: _removed, ...rest } = state.threadIdsByProjectId; + return rest as Record; + })() + : { + ...state.threadIdsByProjectId, + [shell.projectId]: nextProjectThreadIds, + }; + + const { [threadId]: _removedShell, ...threadShellById } = state.threadShellById; + const { [threadId]: _removedSession, ...threadSessionById } = state.threadSessionById; + const { [threadId]: _removedTurnState, ...threadTurnStateById } = state.threadTurnStateById; + const { [threadId]: _removedMessageIds, ...messageIdsByThreadId } = state.messageIdsByThreadId; + const { [threadId]: _removedMessages, ...messageByThreadId } = state.messageByThreadId; + const { [threadId]: _removedActivityIds, ...activityIdsByThreadId } = state.activityIdsByThreadId; + const { [threadId]: _removedActivities, ...activityByThreadId } = state.activityByThreadId; + const { [threadId]: _removedPlanIds, ...proposedPlanIdsByThreadId } = + state.proposedPlanIdsByThreadId; + const { [threadId]: _removedPlans, ...proposedPlanByThreadId } = state.proposedPlanByThreadId; + const { [threadId]: _removedTurnDiffIds, ...turnDiffIdsByThreadId } = state.turnDiffIdsByThreadId; + const { [threadId]: _removedTurnDiffs, ...turnDiffSummaryByThreadId } = + state.turnDiffSummaryByThreadId; + const { [threadId]: _removedSidebarSummary, ...sidebarThreadSummaryById } = + state.sidebarThreadSummaryById; + + return { + ...state, + threadIds: nextThreadIds, + threadIdsByProjectId: nextThreadIdsByProjectId, + threadShellById, + threadSessionById, + threadTurnStateById, + messageIdsByThreadId, + messageByThreadId, + activityIdsByThreadId, + activityByThreadId, + proposedPlanIdsByThreadId, + proposedPlanByThreadId, + turnDiffIdsByThreadId, + turnDiffSummaryByThreadId, + sidebarThreadSummaryById, + }; } function checkpointStatusToLatestTurnState(status: "ready" | "missing" | "error") { @@ -366,10 +720,10 @@ function buildLatestTurn(params: { } function rebindTurnDiffSummariesForAssistantMessage( - turnDiffSummaries: ReadonlyArray, - turnId: Thread["turnDiffSummaries"][number]["turnId"], + turnDiffSummaries: ReadonlyArray, + turnId: TurnId, assistantMessageId: NonNullable["assistantMessageId"], -): Thread["turnDiffSummaries"] { +): TurnDiffSummary[] { let changed = false; const nextSummaries = turnDiffSummaries.map((summary) => { if (summary.turnId !== turnId || summary.assistantMessageId === assistantMessageId) { @@ -456,18 +810,18 @@ function retainThreadMessagesAfterRevert( } function retainThreadActivitiesAfterRevert( - activities: ReadonlyArray, + activities: ReadonlyArray, retainedTurnIds: ReadonlySet, -): Thread["activities"] { +): OrchestrationThreadActivity[] { return activities.filter( (activity) => activity.turnId === null || retainedTurnIds.has(activity.turnId), ); } function retainThreadProposedPlansAfterRevert( - proposedPlans: ReadonlyArray, + proposedPlans: ReadonlyArray, retainedTurnIds: ReadonlySet, -): Thread["proposedPlans"] { +): ProposedPlan[] { return proposedPlans.filter( (proposedPlan) => proposedPlan.turnId === null || retainedTurnIds.has(proposedPlan.turnId), ); @@ -536,56 +890,113 @@ function updateThreadState( threadId: ThreadId, updater: (thread: Thread) => Thread, ): AppState { - let updatedThread: Thread | null = null; - const threads = updateThread(state.threads, threadId, (thread) => { - const nextThread = updater(thread); - if (nextThread !== thread) { - updatedThread = nextThread; - } - return nextThread; - }); - if (threads === state.threads || updatedThread === null) { + const currentThread = getThread(state, threadId); + if (!currentThread) { return state; } + const nextThread = updater(currentThread); + if (nextThread === currentThread) { + return state; + } + return writeThreadState(state, nextThread, currentThread); +} - const nextSummary = buildSidebarThreadSummary(updatedThread); - const previousSummary = state.sidebarThreadsById[threadId]; - const sidebarThreadsById = sidebarThreadSummariesEqual(previousSummary, nextSummary) - ? state.sidebarThreadsById - : { - ...state.sidebarThreadsById, - [threadId]: nextSummary, - }; +function buildProjectState( + projects: ReadonlyArray, +): Pick { + return { + projectIds: projects.map((project) => project.id), + projectById: Object.fromEntries( + projects.map((project) => [project.id, project] as const), + ) as Record, + }; +} - if (sidebarThreadsById === state.sidebarThreadsById) { - return { - ...state, - threads, - }; +function buildThreadState( + threads: ReadonlyArray, +): Pick< + AppState, + | "threadIds" + | "threadIdsByProjectId" + | "threadShellById" + | "threadSessionById" + | "threadTurnStateById" + | "messageIdsByThreadId" + | "messageByThreadId" + | "activityIdsByThreadId" + | "activityByThreadId" + | "proposedPlanIdsByThreadId" + | "proposedPlanByThreadId" + | "turnDiffIdsByThreadId" + | "turnDiffSummaryByThreadId" + | "sidebarThreadSummaryById" +> { + const threadIds: ThreadId[] = []; + const threadIdsByProjectId: Record = {}; + const threadShellById: Record = {}; + const threadSessionById: Record = {}; + const threadTurnStateById: Record = {}; + const messageIdsByThreadId: Record = {}; + const messageByThreadId: Record> = {}; + const activityIdsByThreadId: Record = {}; + const activityByThreadId: Record> = {}; + const proposedPlanIdsByThreadId: Record = {}; + const proposedPlanByThreadId: Record> = {}; + const turnDiffIdsByThreadId: Record = {}; + const turnDiffSummaryByThreadId: Record> = {}; + const sidebarThreadSummaryById: Record = {}; + + for (const thread of threads) { + threadIds.push(thread.id); + threadIdsByProjectId[thread.projectId] = [ + ...(threadIdsByProjectId[thread.projectId] ?? EMPTY_THREAD_IDS), + thread.id, + ]; + threadShellById[thread.id] = toThreadShell(thread); + threadSessionById[thread.id] = thread.session; + threadTurnStateById[thread.id] = toThreadTurnState(thread); + const messageSlice = buildMessageSlice(thread); + messageIdsByThreadId[thread.id] = messageSlice.ids; + messageByThreadId[thread.id] = messageSlice.byId; + const activitySlice = buildActivitySlice(thread); + activityIdsByThreadId[thread.id] = activitySlice.ids; + activityByThreadId[thread.id] = activitySlice.byId; + const proposedPlanSlice = buildProposedPlanSlice(thread); + proposedPlanIdsByThreadId[thread.id] = proposedPlanSlice.ids; + proposedPlanByThreadId[thread.id] = proposedPlanSlice.byId; + const turnDiffSlice = buildTurnDiffSlice(thread); + turnDiffIdsByThreadId[thread.id] = turnDiffSlice.ids; + turnDiffSummaryByThreadId[thread.id] = turnDiffSlice.byId; + sidebarThreadSummaryById[thread.id] = buildSidebarThreadSummary(thread); } return { - ...state, - threads, - sidebarThreadsById, + threadIds, + threadIdsByProjectId, + threadShellById, + threadSessionById, + threadTurnStateById, + messageIdsByThreadId, + messageByThreadId, + activityIdsByThreadId, + activityByThreadId, + proposedPlanIdsByThreadId, + proposedPlanByThreadId, + turnDiffIdsByThreadId, + turnDiffSummaryByThreadId, + sidebarThreadSummaryById, }; } -// ── Pure state transition functions ──────────────────────────────────── - export function syncServerReadModel(state: AppState, readModel: OrchestrationReadModel): AppState { const projects = readModel.projects .filter((project) => project.deletedAt === null) .map(mapProject); const threads = readModel.threads.filter((thread) => thread.deletedAt === null).map(mapThread); - const sidebarThreadsById = buildSidebarThreadsById(threads); - const threadIdsByProjectId = buildThreadIdsByProjectId(threads); return { ...state, - projects, - threads, - sidebarThreadsById, - threadIdsByProjectId, + ...buildProjectState(projects), + ...buildThreadState(threads), bootstrapComplete: true, }; } @@ -593,10 +1004,6 @@ export function syncServerReadModel(state: AppState, readModel: OrchestrationRea export function applyOrchestrationEvent(state: AppState, event: OrchestrationEvent): AppState { switch (event.type) { case "project.created": { - const existingIndex = state.projects.findIndex( - (project) => - project.id === event.payload.projectId || project.cwd === event.payload.workspaceRoot, - ); const nextProject = mapProject({ id: event.payload.projectId, title: event.payload.title, @@ -607,17 +1014,34 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve 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 }; + const existingProjectId = + state.projectIds.find( + (projectId) => + projectId === event.payload.projectId || + state.projectById[projectId]?.cwd === event.payload.workspaceRoot, + ) ?? null; + const resolvedProjectId = existingProjectId ?? nextProject.id; + const projectById = { + ...state.projectById, + [resolvedProjectId]: nextProject, + }; + const projectIds = + existingProjectId === null && !state.projectIds.includes(nextProject.id) + ? [...state.projectIds, nextProject.id] + : state.projectIds; + return { + ...state, + projectById, + projectIds, + }; } case "project.meta-updated": { - const projects = updateProject(state.projects, event.payload.projectId, (project) => ({ + const project = state.projectById[event.payload.projectId]; + if (!project) { + return state; + } + const nextProject: Project = { ...project, ...(event.payload.title !== undefined ? { name: event.payload.title } : {}), ...(event.payload.workspaceRoot !== undefined ? { cwd: event.payload.workspaceRoot } : {}), @@ -632,17 +1056,30 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve ? { scripts: mapProjectScripts(event.payload.scripts) } : {}), updatedAt: event.payload.updatedAt, - })); - return projects === state.projects ? state : { ...state, projects }; + }; + return { + ...state, + projectById: { + ...state.projectById, + [event.payload.projectId]: nextProject, + }, + }; } case "project.deleted": { - const projects = state.projects.filter((project) => project.id !== event.payload.projectId); - return projects.length === state.projects.length ? state : { ...state, projects }; + if (!state.projectById[event.payload.projectId]) { + return state; + } + const { [event.payload.projectId]: _removedProject, ...projectById } = state.projectById; + return { + ...state, + projectById, + projectIds: removeId(state.projectIds, event.payload.projectId), + }; } case "thread.created": { - const existing = state.threads.find((thread) => thread.id === event.payload.threadId); + const previousThread = getThread(state, event.payload.threadId); const nextThread = mapThread({ id: event.payload.threadId, projectId: event.payload.projectId, @@ -663,74 +1100,27 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve checkpoints: [], session: null, }); - const threads = existing - ? state.threads.map((thread) => (thread.id === nextThread.id ? nextThread : thread)) - : [...state.threads, nextThread]; - const nextSummary = buildSidebarThreadSummary(nextThread); - const previousSummary = state.sidebarThreadsById[nextThread.id]; - const sidebarThreadsById = sidebarThreadSummariesEqual(previousSummary, nextSummary) - ? state.sidebarThreadsById - : { - ...state.sidebarThreadsById, - [nextThread.id]: nextSummary, - }; - const nextThreadIdsByProjectId = - existing !== undefined && existing.projectId !== nextThread.projectId - ? removeThreadIdByProjectId(state.threadIdsByProjectId, existing.projectId, existing.id) - : state.threadIdsByProjectId; - const threadIdsByProjectId = appendThreadIdByProjectId( - nextThreadIdsByProjectId, - nextThread.projectId, - nextThread.id, - ); - return { - ...state, - threads, - sidebarThreadsById, - threadIdsByProjectId, - }; + return writeThreadState(state, nextThread, previousThread); } - case "thread.deleted": { - const threads = state.threads.filter((thread) => thread.id !== event.payload.threadId); - if (threads.length === state.threads.length) { - return state; - } - const deletedThread = state.threads.find((thread) => thread.id === event.payload.threadId); - const sidebarThreadsById = { ...state.sidebarThreadsById }; - delete sidebarThreadsById[event.payload.threadId]; - const threadIdsByProjectId = deletedThread - ? removeThreadIdByProjectId( - state.threadIdsByProjectId, - deletedThread.projectId, - deletedThread.id, - ) - : state.threadIdsByProjectId; - return { - ...state, - threads, - sidebarThreadsById, - threadIdsByProjectId, - }; - } + case "thread.deleted": + return removeThreadState(state, event.payload.threadId); - case "thread.archived": { + case "thread.archived": return updateThreadState(state, event.payload.threadId, (thread) => ({ ...thread, archivedAt: event.payload.archivedAt, updatedAt: event.payload.updatedAt, })); - } - case "thread.unarchived": { + case "thread.unarchived": return updateThreadState(state, event.payload.threadId, (thread) => ({ ...thread, archivedAt: null, updatedAt: event.payload.updatedAt, })); - } - case "thread.meta-updated": { + case "thread.meta-updated": return updateThreadState(state, event.payload.threadId, (thread) => ({ ...thread, ...(event.payload.title !== undefined ? { title: event.payload.title } : {}), @@ -743,25 +1133,22 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve : {}), updatedAt: event.payload.updatedAt, })); - } - case "thread.runtime-mode-set": { + case "thread.runtime-mode-set": return updateThreadState(state, event.payload.threadId, (thread) => ({ ...thread, runtimeMode: event.payload.runtimeMode, updatedAt: event.payload.updatedAt, })); - } - case "thread.interaction-mode-set": { + case "thread.interaction-mode-set": return updateThreadState(state, event.payload.threadId, (thread) => ({ ...thread, interactionMode: event.payload.interactionMode, updatedAt: event.payload.updatedAt, })); - } - case "thread.turn-start-requested": { + case "thread.turn-start-requested": return updateThreadState(state, event.payload.threadId, (thread) => ({ ...thread, ...(event.payload.modelSelection !== undefined @@ -772,7 +1159,6 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve pendingSourceProposedPlan: event.payload.sourceProposedPlan, updatedAt: event.occurredAt, })); - } case "thread.turn-interrupt-requested": { if (event.payload.turnId === undefined) { @@ -799,7 +1185,7 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve }); } - case "thread.message-sent": { + case "thread.message-sent": return updateThreadState(state, event.payload.threadId, (thread) => { const message = mapMessage({ id: event.payload.messageId, @@ -888,9 +1274,8 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve updatedAt: event.occurredAt, }; }); - } - case "thread.session-set": { + case "thread.session-set": return updateThreadState(state, event.payload.threadId, (thread) => ({ ...thread, session: mapSession(event.payload.session), @@ -919,9 +1304,8 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve : thread.latestTurn, updatedAt: event.occurredAt, })); - } - case "thread.session-stop-requested": { + case "thread.session-stop-requested": return updateThreadState(state, event.payload.threadId, (thread) => thread.session === null ? thread @@ -937,9 +1321,8 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve updatedAt: event.occurredAt, }, ); - } - case "thread.proposed-plan-upserted": { + case "thread.proposed-plan-upserted": return updateThreadState(state, event.payload.threadId, (thread) => { const proposedPlan = mapProposedPlan(event.payload.proposedPlan); const proposedPlans = [ @@ -957,9 +1340,8 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve updatedAt: event.occurredAt, }; }); - } - case "thread.turn-diff-completed": { + case "thread.turn-diff-completed": return updateThreadState(state, event.payload.threadId, (thread) => { const checkpoint = mapTurnDiffSummary({ turnId: event.payload.turnId, @@ -1006,9 +1388,8 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve updatedAt: event.occurredAt, }; }); - } - case "thread.reverted": { + case "thread.reverted": return updateThreadState(state, event.payload.threadId, (thread) => { const turnDiffSummaries = thread.turnDiffSummaries .filter( @@ -1058,9 +1439,8 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve updatedAt: event.occurredAt, }; }); - } - case "thread.activity-appended": { + case "thread.activity-appended": return updateThreadState(state, event.payload.threadId, (thread) => { const activities = [ ...thread.activities.filter((activity) => activity.id !== event.payload.activity.id), @@ -1074,7 +1454,6 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve updatedAt: event.occurredAt, }; }); - } case "thread.approval-response-requested": case "thread.user-input-response-requested": @@ -1094,30 +1473,29 @@ export function applyOrchestrationEvents( return events.reduce((nextState, event) => applyOrchestrationEvent(nextState, event), state); } +export const selectProjects = (state: AppState): Project[] => getProjects(state); +export const selectThreads = (state: AppState): Thread[] => getThreads(state); export const selectProjectById = (projectId: Project["id"] | null | undefined) => (state: AppState): Project | undefined => - projectId ? state.projects.find((project) => project.id === projectId) : undefined; - + projectId ? state.projectById[projectId] : undefined; export const selectThreadById = (threadId: ThreadId | null | undefined) => (state: AppState): Thread | undefined => - threadId ? state.threads.find((thread) => thread.id === threadId) : undefined; - + threadId ? getThread(state, threadId) : undefined; export const selectSidebarThreadSummaryById = (threadId: ThreadId | null | undefined) => (state: AppState): SidebarThreadSummary | undefined => - threadId ? state.sidebarThreadsById[threadId] : undefined; - + threadId ? state.sidebarThreadSummaryById[threadId] : undefined; export const selectThreadIdsByProjectId = (projectId: ProjectId | null | undefined) => (state: AppState): ThreadId[] => projectId ? (state.threadIdsByProjectId[projectId] ?? EMPTY_THREAD_IDS) : EMPTY_THREAD_IDS; export function setError(state: AppState, threadId: ThreadId, error: string | null): AppState { - return updateThreadState(state, threadId, (t) => { - if (t.error === error) return t; - return { ...t, error }; + return updateThreadState(state, threadId, (thread) => { + if (thread.error === error) return thread; + return { ...thread, error }; }); } @@ -1127,11 +1505,11 @@ export function setThreadBranch( branch: string | null, worktreePath: string | null, ): AppState { - return updateThreadState(state, threadId, (t) => { - if (t.branch === branch && t.worktreePath === worktreePath) return t; - const cwdChanged = t.worktreePath !== worktreePath; + return updateThreadState(state, threadId, (thread) => { + if (thread.branch === branch && thread.worktreePath === worktreePath) return thread; + const cwdChanged = thread.worktreePath !== worktreePath; return { - ...t, + ...thread, branch, worktreePath, ...(cwdChanged ? { session: null } : {}), @@ -1139,8 +1517,6 @@ export function setThreadBranch( }); } -// ── Zustand store ──────────────────────────────────────────────────── - interface AppStore extends AppState { syncServerReadModel: (readModel: OrchestrationReadModel) => void; applyOrchestrationEvent: (event: OrchestrationEvent) => void; diff --git a/apps/web/src/storeSelectors.ts b/apps/web/src/storeSelectors.ts index 65f8e6caaa..a7a7440eb2 100644 --- a/apps/web/src/storeSelectors.ts +++ b/apps/web/src/storeSelectors.ts @@ -1,26 +1,146 @@ -import { type ThreadId } from "@t3tools/contracts"; -import { useMemo } from "react"; +import { type MessageId, type ProjectId, type ThreadId, type TurnId } from "@t3tools/contracts"; +import { type AppState } from "./store"; import { - selectProjectById, - selectSidebarThreadSummaryById, - selectThreadById, - useStore, -} from "./store"; -import { type Project, type SidebarThreadSummary, type Thread } from "./types"; - -export function useProjectById(projectId: Project["id"] | null | undefined): Project | undefined { - const selector = useMemo(() => selectProjectById(projectId), [projectId]); - return useStore(selector); + type ChatMessage, + type Project, + type ProposedPlan, + type SidebarThreadSummary, + type Thread, + type ThreadSession, + type ThreadTurnState, + type TurnDiffSummary, +} from "./types"; + +const EMPTY_MESSAGES: ChatMessage[] = []; +const EMPTY_ACTIVITIES: Thread["activities"] = []; +const EMPTY_PROPOSED_PLANS: ProposedPlan[] = []; +const EMPTY_TURN_DIFF_SUMMARIES: TurnDiffSummary[] = []; + +function collectByIds( + ids: readonly TKey[] | undefined, + byId: Record | undefined, +): TValue[] { + if (!ids || ids.length === 0 || !byId) { + return []; + } + + return ids.flatMap((id) => { + const value = byId[id]; + return value ? [value] : []; + }); } -export function useThreadById(threadId: ThreadId | null | undefined): Thread | undefined { - const selector = useMemo(() => selectThreadById(threadId), [threadId]); - return useStore(selector); +export function createProjectSelector( + projectId: ProjectId | null | undefined, +): (state: AppState) => Project | undefined { + return (state) => (projectId ? state.projectById[projectId] : undefined); +} + +export function createSidebarThreadSummarySelector( + threadId: ThreadId | null | undefined, +): (state: AppState) => SidebarThreadSummary | undefined { + return (state) => (threadId ? state.sidebarThreadSummaryById[threadId] : undefined); } -export function useSidebarThreadSummaryById( +export function createThreadSelector( threadId: ThreadId | null | undefined, -): SidebarThreadSummary | undefined { - const selector = useMemo(() => selectSidebarThreadSummaryById(threadId), [threadId]); - return useStore(selector); +): (state: AppState) => Thread | undefined { + let previousShell: AppState["threadShellById"][ThreadId] | undefined; + let previousSession: ThreadSession | null | undefined; + let previousTurnState: ThreadTurnState | undefined; + let previousMessageIds: MessageId[] | undefined; + let previousMessagesById: AppState["messageByThreadId"][ThreadId] | undefined; + let previousActivityIds: string[] | undefined; + let previousActivitiesById: AppState["activityByThreadId"][ThreadId] | undefined; + let previousProposedPlanIds: string[] | undefined; + let previousProposedPlansById: AppState["proposedPlanByThreadId"][ThreadId] | undefined; + let previousTurnDiffIds: TurnId[] | undefined; + let previousTurnDiffsById: AppState["turnDiffSummaryByThreadId"][ThreadId] | undefined; + let previousThread: Thread | undefined; + + return (state) => { + if (!threadId) { + return undefined; + } + + const shell = state.threadShellById[threadId]; + if (!shell) { + return undefined; + } + + const session = state.threadSessionById[threadId] ?? null; + const turnState = state.threadTurnStateById[threadId]; + const messageIds = state.messageIdsByThreadId[threadId]; + const messageById = state.messageByThreadId[threadId]; + const activityIds = state.activityIdsByThreadId[threadId]; + const activityById = state.activityByThreadId[threadId]; + const proposedPlanIds = state.proposedPlanIdsByThreadId[threadId]; + const proposedPlanById = state.proposedPlanByThreadId[threadId]; + const turnDiffIds = state.turnDiffIdsByThreadId[threadId]; + const turnDiffById = state.turnDiffSummaryByThreadId[threadId]; + + if ( + previousThread && + previousShell === shell && + previousSession === session && + previousTurnState === turnState && + previousMessageIds === messageIds && + previousMessagesById === messageById && + previousActivityIds === activityIds && + previousActivitiesById === activityById && + previousProposedPlanIds === proposedPlanIds && + previousProposedPlansById === proposedPlanById && + previousTurnDiffIds === turnDiffIds && + previousTurnDiffsById === turnDiffById + ) { + return previousThread; + } + + const nextThread: Thread = { + ...shell, + session, + latestTurn: turnState?.latestTurn ?? null, + pendingSourceProposedPlan: turnState?.pendingSourceProposedPlan, + messages: collectByIds(messageIds, messageById) as Thread["messages"] extends ChatMessage[] + ? ChatMessage[] + : never, + activities: collectByIds(activityIds, activityById) as Thread["activities"] extends Array< + infer _ + > + ? Thread["activities"] + : never, + proposedPlans: collectByIds( + proposedPlanIds, + proposedPlanById, + ) as Thread["proposedPlans"] extends ProposedPlan[] ? ProposedPlan[] : never, + turnDiffSummaries: collectByIds( + turnDiffIds, + turnDiffById, + ) as Thread["turnDiffSummaries"] extends TurnDiffSummary[] ? TurnDiffSummary[] : never, + }; + + previousShell = shell; + previousSession = session; + previousTurnState = turnState; + previousMessageIds = messageIds; + previousMessagesById = messageById; + previousActivityIds = activityIds; + previousActivitiesById = activityById; + previousProposedPlanIds = proposedPlanIds; + previousProposedPlansById = proposedPlanById; + previousTurnDiffIds = turnDiffIds; + previousTurnDiffsById = turnDiffById; + previousThread = { + ...nextThread, + messages: nextThread.messages.length === 0 ? EMPTY_MESSAGES : nextThread.messages, + activities: nextThread.activities.length === 0 ? EMPTY_ACTIVITIES : nextThread.activities, + proposedPlans: + nextThread.proposedPlans.length === 0 ? EMPTY_PROPOSED_PLANS : nextThread.proposedPlans, + turnDiffSummaries: + nextThread.turnDiffSummaries.length === 0 + ? EMPTY_TURN_DIFF_SUMMARIES + : nextThread.turnDiffSummaries, + }; + return previousThread; + }; } diff --git a/apps/web/src/types.ts b/apps/web/src/types.ts index 0599b9c989..972cf42bab 100644 --- a/apps/web/src/types.ts +++ b/apps/web/src/types.ts @@ -111,6 +111,27 @@ export interface Thread { activities: OrchestrationThreadActivity[]; } +export interface ThreadShell { + id: ThreadId; + codexThreadId: string | null; + projectId: ProjectId; + title: string; + modelSelection: ModelSelection; + runtimeMode: RuntimeMode; + interactionMode: ProviderInteractionMode; + error: string | null; + createdAt: string; + archivedAt: string | null; + updatedAt?: string | undefined; + branch: string | null; + worktreePath: string | null; +} + +export interface ThreadTurnState { + latestTurn: OrchestrationLatestTurn | null; + pendingSourceProposedPlan?: OrchestrationLatestTurn["sourceProposedPlan"]; +} + export interface SidebarThreadSummary { id: ThreadId; projectId: ProjectId; From 0737ff435c36d33a2e95a12284dd94bba053318d Mon Sep 17 00:00:00 2001 From: justsomelegs <145564979+justsomelegs@users.noreply.github.com> Date: Fri, 3 Apr 2026 10:25:55 +0100 Subject: [PATCH 2/2] Fix project recreation keys and update web tests --- .../web/src/components/ChatView.logic.test.ts | 204 ++++++++++++------ .../components/chat/MessagesTimeline.test.tsx | 2 +- apps/web/src/store.test.ts | 8 +- apps/web/src/store.ts | 32 ++- 4 files changed, 171 insertions(+), 75 deletions(-) diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 8d49bc07f2..cad565247d 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -1,6 +1,7 @@ import { ProjectId, ThreadId, TurnId } from "@t3tools/contracts"; import { afterEach, describe, expect, it, vi } from "vitest"; import { useStore } from "../store"; +import { type Thread } from "../types"; import { MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, @@ -178,7 +179,7 @@ const makeThread = (input?: { startedAt: string | null; completedAt: string | null; } | null; -}) => ({ +}): Thread => ({ id: input?.id ?? ThreadId.makeUnsafe("thread-1"), codexThreadId: null, projectId: ProjectId.makeUnsafe("project-1"), @@ -205,94 +206,172 @@ const makeThread = (input?: { activities: [], }); +function setStoreThreads(threads: ReadonlyArray>) { + const projectId = ProjectId.makeUnsafe("project-1"); + useStore.setState({ + projectIds: [projectId], + projectById: { + [projectId]: { + id: projectId, + name: "Project", + cwd: "/tmp/project", + defaultModelSelection: { + provider: "codex", + model: "gpt-5.4", + }, + createdAt: "2026-03-29T00:00:00.000Z", + updatedAt: "2026-03-29T00:00:00.000Z", + scripts: [], + }, + }, + threadIds: threads.map((thread) => thread.id), + threadIdsByProjectId: { + [projectId]: threads.map((thread) => thread.id), + }, + threadShellById: Object.fromEntries( + threads.map((thread) => [ + thread.id, + { + id: thread.id, + codexThreadId: thread.codexThreadId, + projectId: thread.projectId, + title: thread.title, + modelSelection: thread.modelSelection, + runtimeMode: thread.runtimeMode, + interactionMode: thread.interactionMode, + error: thread.error, + createdAt: thread.createdAt, + archivedAt: thread.archivedAt, + updatedAt: thread.updatedAt, + branch: thread.branch, + worktreePath: thread.worktreePath, + }, + ]), + ), + threadSessionById: Object.fromEntries(threads.map((thread) => [thread.id, thread.session])), + threadTurnStateById: Object.fromEntries( + threads.map((thread) => [ + thread.id, + { + latestTurn: thread.latestTurn, + ...(thread.pendingSourceProposedPlan + ? { pendingSourceProposedPlan: thread.pendingSourceProposedPlan } + : {}), + }, + ]), + ), + messageIdsByThreadId: Object.fromEntries( + threads.map((thread) => [thread.id, thread.messages.map((message) => message.id)]), + ), + messageByThreadId: Object.fromEntries( + threads.map((thread) => [ + thread.id, + Object.fromEntries(thread.messages.map((message) => [message.id, message])), + ]), + ), + activityIdsByThreadId: Object.fromEntries( + threads.map((thread) => [thread.id, thread.activities.map((activity) => activity.id)]), + ), + activityByThreadId: Object.fromEntries( + threads.map((thread) => [ + thread.id, + Object.fromEntries(thread.activities.map((activity) => [activity.id, activity])), + ]), + ), + proposedPlanIdsByThreadId: Object.fromEntries( + threads.map((thread) => [thread.id, thread.proposedPlans.map((plan) => plan.id)]), + ), + proposedPlanByThreadId: Object.fromEntries( + threads.map((thread) => [ + thread.id, + Object.fromEntries(thread.proposedPlans.map((plan) => [plan.id, plan])), + ]), + ), + turnDiffIdsByThreadId: Object.fromEntries( + threads.map((thread) => [ + thread.id, + thread.turnDiffSummaries.map((summary) => summary.turnId), + ]), + ), + turnDiffSummaryByThreadId: Object.fromEntries( + threads.map((thread) => [ + thread.id, + Object.fromEntries(thread.turnDiffSummaries.map((summary) => [summary.turnId, summary])), + ]), + ), + sidebarThreadSummaryById: {}, + bootstrapComplete: true, + }); +} + afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); - useStore.setState((state) => ({ - ...state, - projects: [], - threads: [], - bootstrapComplete: true, - })); + setStoreThreads([]); }); 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, - }, - }), - ], - })); + setStoreThreads([ + 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 })], - })); + setStoreThreads([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, - }, - }), - ], - })); + setStoreThreads([ + 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 })], - })); + setStoreThreads([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, - }, - }), - ], - })); + setStoreThreads([ + 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); }); @@ -304,10 +383,7 @@ describe("waitForStartedServerThread", () => { vi.useFakeTimers(); const threadId = ThreadId.makeUnsafe("thread-timeout"); - useStore.setState((state) => ({ - ...state, - threads: [makeThread({ id: threadId })], - })); + setStoreThreads([makeThread({ id: threadId })]); const promise = waitForStartedServerThread(threadId, 500); await vi.advanceTimersByTimeAsync(500); diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 692438c74a..40d34b36c1 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -95,7 +95,7 @@ describe("MessagesTimeline", () => { expect(markup).toContain("Terminal 1 lines 1-5"); expect(markup).toContain("lucide-terminal"); expect(markup).toContain("yoo what's "); - }); + }, 10_000); it("renders context compaction entries in the normal work log", async () => { const { MessagesTimeline } = await import("./MessagesTimeline"); diff --git a/apps/web/src/store.test.ts b/apps/web/src/store.test.ts index 2294674848..05128905f0 100644 --- a/apps/web/src/store.test.ts +++ b/apps/web/src/store.test.ts @@ -491,6 +491,9 @@ describe("incremental orchestration updates", () => { expect(projectsOf(next)[0]?.id).toBe(recreatedProjectId); expect(projectsOf(next)[0]?.cwd).toBe("/tmp/project"); expect(projectsOf(next)[0]?.name).toBe("Project Recreated"); + expect(next.projectIds).toEqual([recreatedProjectId]); + expect(next.projectById[originalProjectId]).toBeUndefined(); + expect(next.projectById[recreatedProjectId]?.id).toBe(recreatedProjectId); }); it("removes stale project index entries when thread.created recreates a thread under a new project", () => { @@ -660,7 +663,10 @@ describe("incremental orchestration updates", () => { expect(threadsOf(next)[0]?.messages[0]?.text).toBe("hello world"); expect(threadsOf(next)[0]?.latestTurn?.state).toBe("running"); - expect(threadsOf(next)[1]).toBe(thread2); + expect(next.threadShellById[thread2.id]).toBe(state.threadShellById[thread2.id]); + expect(next.threadSessionById[thread2.id]).toBe(state.threadSessionById[thread2.id]); + expect(next.messageIdsByThreadId[thread2.id]).toBe(state.messageIdsByThreadId[thread2.id]); + expect(next.messageByThreadId[thread2.id]).toBe(state.messageByThreadId[thread2.id]); }); it("applies replay batches in sequence and updates session state", () => { diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index 318d92173c..236050ba75 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -1020,15 +1020,29 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve projectId === event.payload.projectId || state.projectById[projectId]?.cwd === event.payload.workspaceRoot, ) ?? null; - const resolvedProjectId = existingProjectId ?? nextProject.id; - const projectById = { - ...state.projectById, - [resolvedProjectId]: nextProject, - }; - const projectIds = - existingProjectId === null && !state.projectIds.includes(nextProject.id) - ? [...state.projectIds, nextProject.id] - : state.projectIds; + let projectById = state.projectById; + let projectIds = state.projectIds; + + if (existingProjectId !== null && existingProjectId !== nextProject.id) { + const { [existingProjectId]: _removedProject, ...restProjectById } = state.projectById; + projectById = { + ...restProjectById, + [nextProject.id]: nextProject, + }; + projectIds = state.projectIds.map((projectId) => + projectId === existingProjectId ? nextProject.id : projectId, + ); + } else { + projectById = { + ...state.projectById, + [nextProject.id]: nextProject, + }; + projectIds = + existingProjectId === null && !state.projectIds.includes(nextProject.id) + ? [...state.projectIds, nextProject.id] + : state.projectIds; + } + return { ...state, projectById,