From 7522d3bde34e7178fb1618ee6ca14ea7c1dc6d0d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 2 Mar 2026 10:23:53 -0800 Subject: [PATCH] refactor(web): remove reducer pattern, use Zustand + pure functions - Main store: replace useReducer with Zustand; pure state transition functions (syncServerReadModel, markThreadVisited, setError, etc.) called via store methods. Persist via subscribe. - Terminal store: single store keyed by threadId; replace dispatchTerminalAction with direct methods (setTerminalOpen, setTerminalHeight, splitTerminal, newTerminal, setActiveTerminal, closeTerminal, setTerminalActivity). Pure reduceThreadTerminalState used internally. - Update all consumers to call dispatch.method(...) and terminal store methods. Fix store tests to use markThreadUnread pure fn. - Fix Fragment import in store.ts; fix unused get param for lint. Made-with: Cursor --- apps/web/src/components/BranchToolbar.tsx | 6 +- apps/web/src/components/ChatView.tsx | 267 ++------ apps/web/src/components/Sidebar.tsx | 16 +- apps/web/src/composerDraftStore.ts | 59 -- apps/web/src/routes/__root.tsx | 7 +- apps/web/src/routes/_chat.settings.tsx | 2 +- apps/web/src/store.test.ts | 138 +--- apps/web/src/store.ts | 796 ++++------------------ apps/web/src/terminalStateStore.ts | 110 +++ apps/web/src/types.ts | 7 - apps/web/src/worktreeCleanup.test.ts | 14 +- 11 files changed, 332 insertions(+), 1090 deletions(-) create mode 100644 apps/web/src/terminalStateStore.ts diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 57bee94c601f8aff09384eeefec6ae84589481f9..f3bb37c5cf2fe2341570376ff9500689314c6634 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -119,7 +119,7 @@ export default function BranchToolbar({ }); } if (hasServerThread) { - setThreadBranchAction(activeThreadId, syncedBranch, null); + dispatch.setThreadBranch(activeThreadId, syncedBranch, null); return; } setDraftThreadContext(threadId, { @@ -142,7 +142,7 @@ export default function BranchToolbar({ const setThreadError = (error: string | null) => { if (!activeThreadId) return; - setThreadErrorAction(activeThreadId, error); + dispatch.setError(activeThreadId, error); }; const setThreadBranch = (branch: string | null, worktreePath: string | null) => { @@ -171,7 +171,7 @@ export default function BranchToolbar({ }); } if (hasServerThread) { - setThreadBranchAction(activeThreadId, branch, worktreePath); + dispatch.setThreadBranch(activeThreadId, branch, worktreePath); return; } setDraftThreadContext(threadId, { diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index e54de6671f753022dd0e0fde1bc2fc9f2d7ab9a0..0233425d0e270918018011023a07d7800a36319c 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -61,7 +61,6 @@ import { type Thread, type TurnDiffSummary, } from "../types"; -import type { ThreadTerminalState } from "../threadTerminalState"; import { basenameOfPath, getVscodeIconUrlForEntry } from "../vscode-icons"; import { useTheme } from "../hooks/useTheme"; import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; @@ -125,6 +124,7 @@ import { useComposerDraftStore, useComposerThreadDraft, } from "../composerDraftStore"; +import { useTerminalStateStore } from "../terminalStateStore"; import { clamp } from "effect/Number"; function formatMessageMeta(createdAt: string, duration: string | null): string { @@ -205,7 +205,6 @@ function buildExpandedImagePreview( function buildLocalDraftThread( threadId: ThreadId, draftThread: DraftThreadState, - draftTerminalState: ThreadTerminalState, fallbackModel: string, error: string | null, ): Thread { @@ -215,13 +214,6 @@ function buildLocalDraftThread( projectId: draftThread.projectId, title: "New thread", model: fallbackModel, - terminalOpen: draftTerminalState.terminalOpen, - terminalHeight: draftTerminalState.terminalHeight, - terminalIds: draftTerminalState.terminalIds, - runningTerminalIds: draftTerminalState.runningTerminalIds, - activeTerminalId: draftTerminalState.activeTerminalId, - terminalGroups: draftTerminalState.terminalGroups, - activeTerminalGroupId: draftTerminalState.activeTerminalGroupId, session: null, messages: [], error, @@ -235,46 +227,6 @@ function buildLocalDraftThread( }; } -function threadTerminalsMatchState(thread: Thread, terminalState: ThreadTerminalState): boolean { - if ( - thread.terminalOpen !== terminalState.terminalOpen || - thread.terminalHeight !== terminalState.terminalHeight || - thread.activeTerminalId !== terminalState.activeTerminalId || - thread.activeTerminalGroupId !== terminalState.activeTerminalGroupId - ) { - return false; - } - if ( - thread.terminalIds.length !== terminalState.terminalIds.length || - thread.runningTerminalIds.length !== terminalState.runningTerminalIds.length || - thread.terminalGroups.length !== terminalState.terminalGroups.length - ) { - return false; - } - if (!thread.terminalIds.every((terminalId, index) => terminalId === terminalState.terminalIds[index])) { - return false; - } - if ( - !thread.runningTerminalIds.every( - (terminalId, index) => terminalId === terminalState.runningTerminalIds[index], - ) - ) { - return false; - } - return thread.terminalGroups.every((group, groupIndex) => { - const terminalStateGroup = terminalState.terminalGroups[groupIndex]; - if (!terminalStateGroup || group.id !== terminalStateGroup.id) { - return false; - } - if (group.terminalIds.length !== terminalStateGroup.terminalIds.length) { - return false; - } - return group.terminalIds.every( - (terminalId, terminalIdIndex) => terminalId === terminalStateGroup.terminalIds[terminalIdIndex], - ); - }); -} - function revokeBlobPreviewUrl(previewUrl: string | undefined): void { if (!previewUrl || typeof URL === "undefined" || !previewUrl.startsWith("blob:")) { return; @@ -578,13 +530,13 @@ export default function ChatView({ threadId }: ChatViewProps) { setMessagesScrollElement(element); }, []); - const setDraftThreadTerminalAction = useComposerDraftStore((s) => s.setDraftThreadTerminalAction); - const clearDraftThreadTerminalState = useComposerDraftStore( - (s) => s.clearDraftThreadTerminalState, - ); - const localDraftTerminalState = useComposerDraftStore((s) => - s.getDraftThreadTerminalState(threadId), - ); + const terminalState = useTerminalStateStore((s) => s.getTerminalState(threadId)); + const storeSetTerminalOpen = useTerminalStateStore((s) => s.setTerminalOpen); + const storeSetTerminalHeight = useTerminalStateStore((s) => s.setTerminalHeight); + const storeSplitTerminal = useTerminalStateStore((s) => s.splitTerminal); + const storeNewTerminal = useTerminalStateStore((s) => s.newTerminal); + const storeSetActiveTerminal = useTerminalStateStore((s) => s.setActiveTerminal); + const storeCloseTerminal = useTerminalStateStore((s) => s.closeTerminal); const setPrompt = useCallback( (nextPrompt: string) => { @@ -624,12 +576,11 @@ export default function ChatView({ threadId }: ChatViewProps) { ? buildLocalDraftThread( threadId, draftThread, - localDraftTerminalState, fallbackDraftProject?.model ?? DEFAULT_MODEL, localDraftError, ) : undefined, - [draftThread, fallbackDraftProject?.model, localDraftError, localDraftTerminalState, threadId], + [draftThread, fallbackDraftProject?.model, localDraftError, threadId], ); const activeThread = serverThread ?? localDraftThread; const isServerThread = serverThread !== undefined; @@ -660,25 +611,6 @@ export default function ChatView({ threadId }: ChatViewProps) { }); }, [serverThread, threadId]); - useEffect(() => { - if (!serverThread) { - return; - } - const draft = useComposerDraftStore.getState().draftThreadsByThreadId[threadId]; - const draftTerminal = draft?.terminalState; - if (draftTerminal === undefined) { - return; - } - if (!threadTerminalsMatchState(serverThread, draftTerminal)) { - dispatch({ - type: "HYDRATE_THREAD_TERMINALS", - threadId: serverThread.id, - terminalState: draftTerminal, - }); - } - clearDraftThreadTerminalState(threadId); - }, [clearDraftThreadTerminalState, dispatch, serverThread, threadId]); - useEffect(() => { if (!activeThread?.id) return; if (!latestTurnSettled) return; @@ -688,7 +620,7 @@ export default function ChatView({ threadId }: ChatViewProps) { const lastVisitedAt = activeThread.lastVisitedAt ? Date.parse(activeThread.lastVisitedAt) : NaN; if (!Number.isNaN(lastVisitedAt) && lastVisitedAt >= turnCompletedAt) return; - markThreadVisited(activeThread.id); + dispatch.markThreadVisited(activeThread.id); }, [ activeThread?.id, activeThread?.lastVisitedAt, @@ -1045,16 +977,12 @@ export default function ChatView({ threadId }: ChatViewProps) { (activeThread.session !== null && activeThread.session.status !== "closed")), ); const hasReachedTerminalLimit = - (activeThread?.terminalIds.length ?? 0) >= MAX_THREAD_TERMINAL_COUNT; + terminalState.terminalIds.length >= MAX_THREAD_TERMINAL_COUNT; const setThreadError = useCallback( (targetThreadId: ThreadId | null, error: string | null) => { if (!targetThreadId) return; if (state.threads.some((thread) => thread.id === targetThreadId)) { - dispatch({ - type: "SET_ERROR", - threadId: targetThreadId, - error, - }); + dispatch.setError(targetThreadId, error); return; } setLocalDraftErrorsByThreadId((existing) => { @@ -1085,86 +1013,46 @@ export default function ChatView({ threadId }: ChatViewProps) { const setTerminalOpen = useCallback( (open: boolean) => { if (!activeThreadId) return; - if (isLocalDraftThread) { - setDraftThreadTerminalAction(activeThreadId, { type: "set-open", open }); - return; - } - dispatch({ - type: "SET_THREAD_TERMINAL_OPEN", - threadId: activeThreadId, - open, - }); + storeSetTerminalOpen(activeThreadId, open); }, - [activeThreadId, dispatch, isLocalDraftThread, setDraftThreadTerminalAction], + [activeThreadId, storeSetTerminalOpen], ); const setTerminalHeight = useCallback( (height: number) => { if (!activeThreadId) return; - if (isLocalDraftThread) { - setDraftThreadTerminalAction(activeThreadId, { type: "set-height", height }); - return; - } - dispatch({ - type: "SET_THREAD_TERMINAL_HEIGHT", - threadId: activeThreadId, - height, - }); + storeSetTerminalHeight(activeThreadId, height); }, - [activeThreadId, dispatch, isLocalDraftThread, setDraftThreadTerminalAction], + [activeThreadId, storeSetTerminalHeight], ); const toggleTerminalVisibility = useCallback(() => { if (!activeThreadId) return; - setTerminalOpen(!activeThread?.terminalOpen); - }, [activeThread?.terminalOpen, activeThreadId, setTerminalOpen]); + setTerminalOpen(!terminalState.terminalOpen); + }, [activeThreadId, setTerminalOpen, terminalState.terminalOpen]); const splitTerminal = useCallback(() => { if (!activeThreadId || hasReachedTerminalLimit) return; const terminalId = `terminal-${crypto.randomUUID()}`; - if (isLocalDraftThread) { - setDraftThreadTerminalAction(activeThreadId, { type: "split", terminalId }); - } else { - dispatch({ - type: "SPLIT_THREAD_TERMINAL", - threadId: activeThreadId, - terminalId, - }); - } + storeSplitTerminal(activeThreadId, terminalId); setTerminalFocusRequestId((value) => value + 1); - }, [activeThreadId, dispatch, hasReachedTerminalLimit, isLocalDraftThread, setDraftThreadTerminalAction]); + }, [activeThreadId, storeSplitTerminal, hasReachedTerminalLimit]); const createNewTerminal = useCallback(() => { if (!activeThreadId || hasReachedTerminalLimit) return; const terminalId = `terminal-${crypto.randomUUID()}`; - if (isLocalDraftThread) { - setDraftThreadTerminalAction(activeThreadId, { type: "new", terminalId }); - } else { - dispatch({ - type: "NEW_THREAD_TERMINAL", - threadId: activeThreadId, - terminalId, - }); - } + storeNewTerminal(activeThreadId, terminalId); setTerminalFocusRequestId((value) => value + 1); - }, [activeThreadId, dispatch, hasReachedTerminalLimit, isLocalDraftThread, setDraftThreadTerminalAction]); + }, [activeThreadId, storeNewTerminal, hasReachedTerminalLimit]); const activateTerminal = useCallback( (terminalId: string) => { if (!activeThreadId) return; - if (isLocalDraftThread) { - setDraftThreadTerminalAction(activeThreadId, { type: "set-active", terminalId }); - } else { - dispatch({ - type: "SET_THREAD_ACTIVE_TERMINAL", - threadId: activeThreadId, - terminalId, - }); - } + storeSetActiveTerminal(activeThreadId, terminalId); setTerminalFocusRequestId((value) => value + 1); }, - [activeThreadId, dispatch, isLocalDraftThread, setDraftThreadTerminalAction], + [activeThreadId, storeSetActiveTerminal], ); const closeTerminal = useCallback( (terminalId: string) => { const api = readNativeApi(); if (!activeThreadId || !api) return; - const isFinalTerminal = (activeThread?.terminalIds.length ?? 0) <= 1; + const isFinalTerminal = terminalState.terminalIds.length <= 1; const fallbackExitWrite = () => api.terminal .write({ threadId: activeThreadId, terminalId, data: "exit\n" }) @@ -1181,18 +1069,10 @@ export default function ChatView({ threadId }: ChatViewProps) { } else { void fallbackExitWrite(); } - if (isLocalDraftThread) { - setDraftThreadTerminalAction(activeThreadId, { type: "close", terminalId }); - } else { - dispatch({ - type: "CLOSE_THREAD_TERMINAL", - threadId: activeThreadId, - terminalId, - }); - } + storeCloseTerminal(activeThreadId, terminalId); setTerminalFocusRequestId((value) => value + 1); }, - [activeThread?.terminalIds.length, activeThreadId, dispatch, isLocalDraftThread, setDraftThreadTerminalAction], + [activeThreadId, storeCloseTerminal, terminalState.terminalIds.length], ); const runProjectScript = useCallback( async ( @@ -1217,42 +1097,22 @@ export default function ChatView({ threadId }: ChatViewProps) { } const targetCwd = options?.cwd ?? gitCwd ?? activeProject.cwd; const baseTerminalId = - activeThread.activeTerminalId || activeThread.terminalIds[0] || DEFAULT_THREAD_TERMINAL_ID; - const isBaseTerminalBusy = activeThread.runningTerminalIds.includes(baseTerminalId); + terminalState.activeTerminalId || + terminalState.terminalIds[0] || + DEFAULT_THREAD_TERMINAL_ID; + const isBaseTerminalBusy = terminalState.runningTerminalIds.includes(baseTerminalId); const wantsNewTerminal = Boolean(options?.preferNewTerminal) || isBaseTerminalBusy; const shouldCreateNewTerminal = - wantsNewTerminal && activeThread.terminalIds.length < MAX_THREAD_TERMINAL_COUNT; + wantsNewTerminal && terminalState.terminalIds.length < MAX_THREAD_TERMINAL_COUNT; const targetTerminalId = shouldCreateNewTerminal ? `terminal-${crypto.randomUUID()}` : baseTerminalId; setTerminalOpen(true); if (shouldCreateNewTerminal) { - if (isLocalDraftThread) { - setDraftThreadTerminalAction(activeThreadId, { - type: "new", - terminalId: targetTerminalId, - }); - } else { - dispatch({ - type: "NEW_THREAD_TERMINAL", - threadId: activeThreadId, - terminalId: targetTerminalId, - }); - } + storeNewTerminal(activeThreadId, targetTerminalId); } else { - if (isLocalDraftThread) { - setDraftThreadTerminalAction(activeThreadId, { - type: "set-active", - terminalId: targetTerminalId, - }); - } else { - dispatch({ - type: "SET_THREAD_ACTIVE_TERMINAL", - threadId: activeThreadId, - terminalId: targetTerminalId, - }); - } + storeSetActiveTerminal(activeThreadId, targetTerminalId); } setTerminalFocusRequestId((value) => value + 1); @@ -1299,7 +1159,8 @@ export default function ChatView({ threadId }: ChatViewProps) { isServerThread, setTerminalOpen, setThreadError, - setDraftThreadTerminalAction, + storeNewTerminal, + storeSetActiveTerminal, ], ); const persistProjectScripts = useCallback( @@ -1403,8 +1264,8 @@ export default function ChatView({ threadId }: ChatViewProps) { ); const handleRuntimeModeChange = async (mode: "approval-required" | "full-access") => { - if (mode === runtimeMode) return; - setRuntimeMode(mode); + if (mode === state.runtimeMode) return; + dispatch.setRuntimeMode(mode); scheduleComposerFocus(); const api = readNativeApi(); if (!api) return; @@ -1544,14 +1405,14 @@ export default function ChatView({ threadId }: ChatViewProps) { }, [activeThread?.id]); useEffect(() => { - if (!activeThread?.id || activeThread.terminalOpen) return; + if (!activeThread?.id || terminalState.terminalOpen) return; const frame = window.requestAnimationFrame(() => { focusComposer(); }); return () => { window.cancelAnimationFrame(frame); }; - }, [activeThread?.id, activeThread?.terminalOpen, focusComposer]); + }, [activeThread?.id, focusComposer, terminalState.terminalOpen]); useEffect(() => { composerImagesRef.current = composerImages; @@ -1762,7 +1623,7 @@ export default function ChatView({ threadId }: ChatViewProps) { useEffect(() => { if (!activeThreadId) return; const previous = terminalOpenByThreadRef.current[activeThreadId] ?? false; - const current = Boolean(activeThread?.terminalOpen); + const current = Boolean(terminalState.terminalOpen); if (!previous && current) { setTerminalFocusRequestId((value) => value + 1); @@ -1777,7 +1638,7 @@ export default function ChatView({ threadId }: ChatViewProps) { } terminalOpenByThreadRef.current[activeThreadId] = current; - }, [activeThread?.terminalOpen, activeThreadId, focusComposer]); + }, [activeThreadId, focusComposer, terminalState.terminalOpen]); useEffect(() => { const isTerminalFocused = (): boolean => { @@ -1791,7 +1652,7 @@ export default function ChatView({ threadId }: ChatViewProps) { if (!activeThreadId || event.defaultPrevented) return; const shortcutContext = { terminalFocus: isTerminalFocused(), - terminalOpen: Boolean(activeThread?.terminalOpen), + terminalOpen: Boolean(terminalState.terminalOpen), }; const command = resolveShortcutCommand(event, keybindings, { context: shortcutContext }); @@ -1807,7 +1668,7 @@ export default function ChatView({ threadId }: ChatViewProps) { if (command === "terminal.split") { event.preventDefault(); event.stopPropagation(); - if (!activeThread?.terminalOpen) { + if (!terminalState.terminalOpen) { setTerminalOpen(true); } splitTerminal(); @@ -1817,15 +1678,15 @@ export default function ChatView({ threadId }: ChatViewProps) { if (command === "terminal.close") { event.preventDefault(); event.stopPropagation(); - if (!activeThread?.terminalOpen) return; - closeTerminal(activeThread.activeTerminalId); + if (!terminalState.terminalOpen) return; + closeTerminal(terminalState.activeTerminalId); return; } if (command === "terminal.new") { event.preventDefault(); event.stopPropagation(); - if (!activeThread?.terminalOpen) { + if (!terminalState.terminalOpen) { setTerminalOpen(true); } createNewTerminal(); @@ -1851,8 +1712,8 @@ export default function ChatView({ threadId }: ChatViewProps) { return () => window.removeEventListener("keydown", handler); }, [ activeProject, - activeThread?.terminalOpen, - activeThread?.activeTerminalId, + terminalState.terminalOpen, + terminalState.activeTerminalId, activeThreadId, closeTerminal, createNewTerminal, @@ -2028,10 +1889,7 @@ export default function ChatView({ threadId }: ChatViewProps) { const shouldCreateWorktree = isFirstMessage && envMode === "worktree" && !activeThread.worktreePath; if (shouldCreateWorktree && !activeThread.branch) { - setThreadErrorAction( - threadIdForSend, - "Select a base branch before sending in New worktree mode.", - ); + dispatch.setError(threadIdForSend, "Select a base branch before sending in New worktree mode."); return; } @@ -2104,11 +1962,7 @@ export default function ChatView({ threadId }: ChatViewProps) { }); // Keep local thread state in sync immediately so terminal drawer opens // with the worktree cwd/env instead of briefly using the project root. - setThreadBranchAction( - threadIdForSend, - result.worktree.branch, - result.worktree.path, - ); + dispatch.setThreadBranch(threadIdForSend, result.worktree.branch, result.worktree.path); } } @@ -2247,10 +2101,9 @@ export default function ChatView({ threadId }: ChatViewProps) { createdAt: new Date().toISOString(), }); } catch (err) { - setThreadErrorAction( - activeThreadId, - err instanceof Error ? err.message : "Failed to submit approval decision.", - ); + dispatch.setError(activeThreadId, err instanceof Error ? err.message : "Failed to submit approval decision."); + } finally { + setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId)); } setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId)); }, @@ -2792,7 +2645,7 @@ export default function ChatView({ threadId }: ChatViewProps) { )} {(() => { - if (!activeThread.terminalOpen || !activeProject) { + if (!terminalState.terminalOpen || !activeProject) { return null; } return ( @@ -2801,11 +2654,11 @@ export default function ChatView({ threadId }: ChatViewProps) { threadId={activeThread.id} cwd={gitCwd ?? activeProject.cwd} runtimeEnv={threadTerminalRuntimeEnv} - height={activeThread.terminalHeight} - terminalIds={activeThread.terminalIds} - activeTerminalId={activeThread.activeTerminalId} - terminalGroups={activeThread.terminalGroups} - activeTerminalGroupId={activeThread.activeTerminalGroupId} + height={terminalState.terminalHeight} + terminalIds={terminalState.terminalIds} + activeTerminalId={terminalState.activeTerminalId} + terminalGroups={terminalState.terminalGroups} + activeTerminalGroupId={terminalState.activeTerminalGroupId} focusRequestId={terminalFocusRequestId} onSplitTerminal={splitTerminal} onNewTerminal={createNewTerminal} diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 412f2eef1765dc9e06ffcdcbe2b7a5102d05efd1..284f56ae3711f641074af5b7fe72d0540389a434 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -21,6 +21,7 @@ import { gitRemoveWorktreeMutationOptions, gitStatusQueryOptions } from "../lib/ import { serverConfigQueryOptions } from "../lib/serverReactQuery"; import { readNativeApi } from "../nativeApi"; import { type DraftThreadEnvMode, useComposerDraftStore } from "../composerDraftStore"; +import { useTerminalStateStore } from "../terminalStateStore"; import { toastManager } from "./ui/toast"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./ui/collapsible"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; @@ -126,8 +127,10 @@ function threadStatusPill(thread: Thread, hasPendingApprovals: boolean): ThreadS return null; } -function terminalStatusIndicator(thread: Thread): TerminalStatusIndicator | null { - if (thread.runningTerminalIds.length === 0) { +function terminalStatusFromRunningIds( + runningTerminalIds: string[], +): TerminalStatusIndicator | null { + if (runningTerminalIds.length === 0) { return null; } return { @@ -237,6 +240,7 @@ export default function Sidebar() { (store) => store.getDraftThreadByProjectId, ); const getDraftThread = useComposerDraftStore((store) => store.getDraftThread); + const getTerminalState = useTerminalStateStore((s) => s.getTerminalState); const setProjectDraftThreadId = useComposerDraftStore((store) => store.setProjectDraftThreadId); const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); const clearProjectDraftThreadId = useComposerDraftStore( @@ -505,7 +509,7 @@ export default function Sidebar() { position, ); if (clicked === "mark-unread") { - markThreadUnread(threadId); + dispatch.markThreadUnread(threadId); return; } if (clicked !== "delete") return; @@ -777,7 +781,7 @@ export default function Sidebar() { open={project.expanded} onOpenChange={(open) => { if (open === project.expanded) return; - toggleProject(project.id); + dispatch.toggleProject(project.id); }} > @@ -819,7 +823,9 @@ export default function Sidebar() { pendingApprovalByThreadId.get(thread.id) === true, ); const prStatus = prStatusIndicator(prByThreadId.get(thread.id) ?? null); - const terminalStatus = terminalStatusIndicator(thread); + const terminalStatus = terminalStatusFromRunningIds( + getTerminalState(thread.id).runningTerminalIds, + ); return ( diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index c5453e23edef93edb2d51f9913108282c6bd6026..47fdd719221d3daa03dde04f76ff51c597d91f46 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -6,13 +6,6 @@ import { normalizeModelSlug, type ReasoningEffort, } from "@t3tools/contracts"; -import { - createDefaultThreadTerminalState, - getDefaultThreadTerminalState, - reduceThreadTerminalState, - type ThreadTerminalAction, - type ThreadTerminalState, -} from "./threadTerminalState"; import type { ChatImageAttachment } from "./types"; import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; @@ -71,8 +64,6 @@ export interface DraftThreadState { branch: string | null; worktreePath: string | null; envMode: DraftThreadEnvMode; - /** Terminal layout for draft thread; same shape as Thread terminal slice. Omitted when not set. */ - terminalState?: ThreadTerminalState; } interface ProjectDraftThread extends DraftThreadState { @@ -122,9 +113,6 @@ interface ComposerDraftStoreState { ) => void; clearComposerContent: (threadId: ThreadId) => void; clearThreadDraft: (threadId: ThreadId) => void; - getDraftThreadTerminalState: (threadId: ThreadId) => ThreadTerminalState; - setDraftThreadTerminalAction: (threadId: ThreadId, action: ThreadTerminalAction) => void; - clearDraftThreadTerminalState: (threadId: ThreadId) => void; } const EMPTY_PERSISTED_DRAFT_STORE_STATE: PersistedComposerDraftStoreState = { @@ -678,53 +666,6 @@ export const useComposerDraftStore = create()( }; }); }, - getDraftThreadTerminalState: (threadId) => { - if (threadId.length === 0) { - return getDefaultThreadTerminalState(); - } - const draft = get().draftThreadsByThreadId[threadId]; - return draft?.terminalState ?? getDefaultThreadTerminalState(); - }, - setDraftThreadTerminalAction: (threadId, action) => { - if (threadId.length === 0) { - return; - } - set((state) => { - const existing = state.draftThreadsByThreadId[threadId]; - if (!existing) { - return state; - } - const current = existing.terminalState ?? createDefaultThreadTerminalState(); - const next = reduceThreadTerminalState(current, action); - if (next === current) { - return state; - } - return { - draftThreadsByThreadId: { - ...state.draftThreadsByThreadId, - [threadId]: { ...existing, terminalState: next }, - }, - }; - }); - }, - clearDraftThreadTerminalState: (threadId) => { - if (threadId.length === 0) { - return; - } - set((state) => { - const existing = state.draftThreadsByThreadId[threadId]; - if (!existing || existing.terminalState === undefined) { - return state; - } - const { terminalState: _removed, ...rest } = existing; - return { - draftThreadsByThreadId: { - ...state.draftThreadsByThreadId, - [threadId]: rest, - }, - }; - }); - }, setPrompt: (threadId, prompt) => { if (threadId.length === 0) { return; diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 54b32b15bf7eae55d07043d8216c5f2387cf2da1..ec2f636b45ccf308813d51eea1b50397d57a21be 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -15,6 +15,7 @@ import { AnchoredToastProvider, ToastProvider, toastManager } from "../component import { serverConfigQueryOptions, serverQueryKeys } from "../lib/serverReactQuery"; import { readNativeApi } from "../nativeApi"; import { useStore } from "../store"; +import { useTerminalStateStore } from "../terminalStateStore"; import { preferredTerminalEditor } from "../terminal-links"; import { terminalRunningSubprocessFromEvent } from "../terminalActivity"; import { onServerConfigUpdated, onServerWelcome } from "../wsNativeApi"; @@ -149,7 +150,7 @@ function EventRouter() { const snapshot = await api.orchestration.getSnapshot(); if (disposed) return; latestSequence = Math.max(latestSequence, snapshot.snapshotSequence); - syncServerReadModel(snapshot); + dispatch.syncServerReadModel(snapshot); if (pending) { pending = false; await flushSnapshotSync(); @@ -188,7 +189,7 @@ function EventRouter() { if (hasRunningSubprocess === null) { return; } - setThreadTerminalActivity( + useTerminalStateStore.getState().setTerminalActivity( ThreadId.makeUnsafe(event.threadId), event.terminalId, hasRunningSubprocess, @@ -204,7 +205,7 @@ function EventRouter() { if (!payload.bootstrapProjectId || !payload.bootstrapThreadId) { return; } - setProjectExpanded(payload.bootstrapProjectId, true); + dispatch.setProjectExpanded(payload.bootstrapProjectId, true); if (pathnameRef.current !== "/") { return; diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index 7bfe3cb750e19596511d4a3cc657e2a47cf0ee85..d4e8add9ccac88ac5d48d55235143deed06a16ed 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -349,7 +349,7 @@ function SettingsRouteView() { : "border-border bg-background text-muted-foreground hover:bg-accent" }`} onClick={() => { - setRuntimeMode(option.value); + dispatch.setRuntimeMode(option.value); }} > diff --git a/apps/web/src/store.test.ts b/apps/web/src/store.test.ts index 743d85b67b3a3be01b87ce7c6f212f7be3e92264..2514d32619acac907db49d5906aa74393825f022 100644 --- a/apps/web/src/store.test.ts +++ b/apps/web/src/store.test.ts @@ -1,8 +1,8 @@ import { ProjectId, ThreadId, TurnId } from "@t3tools/contracts"; import { beforeEach, describe, expect, it } from "vitest"; -import { type AppState, useStore } from "./store"; -import { DEFAULT_THREAD_TERMINAL_HEIGHT, DEFAULT_THREAD_TERMINAL_ID, type Thread } from "./types"; +import { markThreadUnread, type AppState } from "./store"; +import type { Thread } from "./types"; function makeThread(overrides: Partial = {}): Thread { return { @@ -11,18 +11,6 @@ function makeThread(overrides: Partial = {}): Thread { projectId: ProjectId.makeUnsafe("project-1"), title: "Thread", model: "gpt-5-codex", - terminalOpen: false, - terminalHeight: DEFAULT_THREAD_TERMINAL_HEIGHT, - terminalIds: [DEFAULT_THREAD_TERMINAL_ID], - runningTerminalIds: [], - activeTerminalId: DEFAULT_THREAD_TERMINAL_ID, - terminalGroups: [ - { - id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, - terminalIds: [DEFAULT_THREAD_TERMINAL_ID], - }, - ], - activeTerminalGroupId: `group-${DEFAULT_THREAD_TERMINAL_ID}`, session: null, messages: [], turnDiffSummaries: [], @@ -54,17 +42,8 @@ function makeState(thread: Thread): AppState { }; } -describe("store markThreadUnread action", () => { - beforeEach(() => { - useStore.setState({ - projects: [], - threads: [], - threadsHydrated: false, - runtimeMode: "full-access", - }); - }); - - it("marks a completed thread as unread by moving lastVisitedAt before completion", () => { +describe("store pure functions", () => { + it("markThreadUnread moves lastVisitedAt before completion for a completed thread", () => { const latestTurnCompletedAt = "2026-02-25T12:30:00.000Z"; const initialState = makeState( makeThread({ @@ -80,8 +59,7 @@ describe("store markThreadUnread action", () => { }), ); - useStore.setState(initialState); - useStore.getState().markThreadUnread(ThreadId.makeUnsafe("thread-1")); + const next = markThreadUnread(initialState, ThreadId.makeUnsafe("thread-1")); const updatedThread = useStore.getState().threads[0]; expect(updatedThread).toBeDefined(); @@ -91,7 +69,7 @@ describe("store markThreadUnread action", () => { ); }); - it("does not change a thread without a completed turn", () => { + it("markThreadUnread does not change a thread without a completed turn", () => { const initialState = makeState( makeThread({ latestTurn: null, @@ -99,110 +77,8 @@ describe("store markThreadUnread action", () => { }), ); - useStore.setState(initialState); - useStore.getState().markThreadUnread(ThreadId.makeUnsafe("thread-1")); + const next = markThreadUnread(initialState, ThreadId.makeUnsafe("thread-1")); expect(useStore.getState().threads[0]?.lastVisitedAt).toBe("2026-02-25T12:35:00.000Z"); }); }); - -describe("store terminal activity action", () => { - beforeEach(() => { - useStore.setState({ - projects: [], - threads: [], - threadsHydrated: false, - runtimeMode: "full-access", - }); - }); - - it("adds a terminal to runningTerminalIds when subprocess activity starts", () => { - const state = makeState( - makeThread({ - terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "alt"], - terminalGroups: [ - { - id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, - terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "alt"], - }, - ], - }), - ); - useStore.setState(state); - useStore - .getState() - .setThreadTerminalActivity(ThreadId.makeUnsafe("thread-1"), "alt", true); - - expect(useStore.getState().threads[0]?.runningTerminalIds).toEqual(["alt"]); - }); - - it("removes a terminal from runningTerminalIds when subprocess activity stops", () => { - const state = makeState( - makeThread({ - terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "alt"], - terminalGroups: [ - { - id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, - terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "alt"], - }, - ], - runningTerminalIds: ["alt"], - }), - ); - useStore.setState(state); - useStore - .getState() - .setThreadTerminalActivity(ThreadId.makeUnsafe("thread-1"), "alt", false); - - expect(useStore.getState().threads[0]?.runningTerminalIds).toEqual([]); - }); - - it("ignores activity events for unknown terminal ids", () => { - const state = makeState(makeThread()); - useStore.setState(state); - useStore - .getState() - .setThreadTerminalActivity(ThreadId.makeUnsafe("thread-1"), "missing", true); - - expect(useStore.getState().threads[0]?.runningTerminalIds).toEqual([]); - }); - - it("hydrates terminal state for an existing thread", () => { - const state = makeState(makeThread()); - const next = reducer(state, { - type: "HYDRATE_THREAD_TERMINALS", - threadId: ThreadId.makeUnsafe("thread-1"), - terminalState: { - terminalOpen: true, - terminalHeight: 360, - terminalIds: ["default", "terminal-2"], - runningTerminalIds: ["terminal-2"], - activeTerminalId: "terminal-2", - terminalGroups: [ - { - id: "group-default", - terminalIds: ["default"], - }, - { - id: "group-terminal-2", - terminalIds: ["terminal-2"], - }, - ], - activeTerminalGroupId: "group-terminal-2", - }, - }); - - expect(next.threads[0]).toMatchObject({ - terminalOpen: true, - terminalHeight: 360, - terminalIds: ["default", "terminal-2"], - runningTerminalIds: ["terminal-2"], - activeTerminalId: "terminal-2", - terminalGroups: [ - { id: "group-default", terminalIds: ["default"] }, - { id: "group-terminal-2", terminalIds: ["terminal-2"] }, - ], - activeTerminalGroupId: "group-terminal-2", - }); - }); -}); diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index 9e27c6cc65f8ed96f9de0cc7da3967f1538c24b4..1b1bc9d288fa7510a4a791cde29a287f02c0b94c 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -1,6 +1,4 @@ -import { type ReactNode } from "react"; -import { create } from "zustand"; - +import { Fragment, type ReactNode, createElement, useEffect } from "react"; import { DEFAULT_MODEL, ProviderSessionId, @@ -9,12 +7,7 @@ import { type OrchestrationSessionStatus, resolveModelSlug, } from "@t3tools/contracts"; -import { - createDefaultThreadTerminalState, - normalizeThreadTerminalState, - reduceThreadTerminalState, - type ThreadTerminalState, -} from "./threadTerminalState"; +import { create } from "zustand"; import { DEFAULT_RUNTIME_MODE, type ChatMessage, @@ -23,37 +16,6 @@ import { type Thread, } from "./types"; -// ── Actions ────────────────────────────────────────────────────────── - -type Action = - | { type: "SYNC_SERVER_READ_MODEL"; readModel: OrchestrationReadModel } - | { type: "HYDRATE_THREAD_TERMINALS"; threadId: ThreadId; terminalState: ThreadTerminalState } - | { type: "MARK_THREAD_VISITED"; threadId: ThreadId; visitedAt?: string } - | { type: "MARK_THREAD_UNREAD"; threadId: ThreadId } - | { type: "TOGGLE_PROJECT"; projectId: Project["id"] } - | { - type: "SET_THREAD_TERMINAL_ACTIVITY"; - threadId: ThreadId; - terminalId: string; - hasRunningSubprocess: boolean; - } - | { type: "SET_PROJECT_EXPANDED"; projectId: Project["id"]; expanded: boolean } - | { type: "TOGGLE_THREAD_TERMINAL"; threadId: ThreadId } - | { type: "SET_THREAD_TERMINAL_OPEN"; threadId: ThreadId; open: boolean } - | { type: "SET_THREAD_TERMINAL_HEIGHT"; threadId: ThreadId; height: number } - | { type: "SPLIT_THREAD_TERMINAL"; threadId: ThreadId; terminalId: string } - | { type: "NEW_THREAD_TERMINAL"; threadId: ThreadId; terminalId: string } - | { type: "SET_THREAD_ACTIVE_TERMINAL"; threadId: ThreadId; terminalId: string } - | { type: "CLOSE_THREAD_TERMINAL"; threadId: ThreadId; terminalId: string } - | { type: "SET_ERROR"; threadId: ThreadId; error: string | null } - | { - type: "SET_THREAD_BRANCH"; - threadId: ThreadId; - branch: string | null; - worktreePath: string | null; - } - | { type: "SET_RUNTIME_MODE"; mode: RuntimeMode }; - // ── State ──────────────────────────────────────────────────────────── export interface AppState { @@ -106,11 +68,10 @@ const initialState: AppState = { }; const persistedExpandedProjectCwds = new Set(); -// ── Helpers ────────────────────────────────────────────────────────── +// ── Persist helpers ────────────────────────────────────────────────── function readPersistedState(): AppState { if (typeof window === "undefined") return initialState; - try { const raw = window.localStorage.getItem(PERSISTED_STATE_KEY); if (!raw) return initialState; @@ -138,7 +99,6 @@ function readPersistedState(): AppState { function persistState(state: AppState): void { if (typeof window === "undefined") return; - try { window.localStorage.setItem( PERSISTED_STATE_KEY, @@ -157,6 +117,8 @@ function persistState(state: AppState): void { } } +// ── Pure helpers ────────────────────────────────────────────────────── + function updateThread( threads: Thread[], threadId: ThreadId, @@ -212,9 +174,7 @@ function toLegacyProvider(providerName: string | null): "codex" | "claudeCode" { } function resolveWsHttpOrigin(): string { - if (typeof window === "undefined") { - return ""; - } + if (typeof window === "undefined") return ""; const bridgeWsUrl = window.desktopBridge?.getWsUrl?.(); const envWsUrl = import.meta.env.VITE_WS_URL as string | undefined; const wsCandidate = @@ -223,10 +183,7 @@ function resolveWsHttpOrigin(): string { : typeof envWsUrl === "string" && envWsUrl.length > 0 ? envWsUrl : null; - if (!wsCandidate) { - return window.location.origin; - } - + if (!wsCandidate) return window.location.origin; try { const wsUrl = new URL(wsCandidate); const protocol = @@ -252,328 +209,61 @@ function attachmentPreviewRoutePath(attachmentId: string): string { return `/attachments/${encodeURIComponent(attachmentId)}`; } -function threadTerminalSlice(thread: Thread): ThreadTerminalState { - return { - terminalOpen: thread.terminalOpen, - terminalHeight: thread.terminalHeight, - terminalIds: thread.terminalIds, - runningTerminalIds: thread.runningTerminalIds, - activeTerminalId: thread.activeTerminalId, - terminalGroups: thread.terminalGroups, - activeTerminalGroupId: thread.activeTerminalGroupId, - }; -} +// ── Pure state transition functions ──────────────────────────────────── -function applyTerminalState(thread: Thread, terminal: ThreadTerminalState): Thread { - return { ...thread, ...terminal }; -} - -function normalizeThreadTerminals(thread: Thread): Thread { - return applyTerminalState(thread, normalizeThreadTerminalState(threadTerminalSlice(thread))); -} - -function syncServerReadModelState(state: AppState, readModel: OrchestrationReadModel): AppState { +export function syncServerReadModel( + state: AppState, + readModel: OrchestrationReadModel, +): AppState { const projects = mapProjectsFromReadModel( readModel.projects.filter((project) => project.deletedAt === null), state.projects, ); - const existingThreadById = new Map(state.threads.map((thread) => [thread.id, thread] as const)); + const existingThreadById = new Map( + state.threads.map((thread) => [thread.id, thread] as const), + ); const threads = readModel.threads .filter((thread) => thread.deletedAt === null) .map((thread) => { const existing = existingThreadById.get(thread.id); - -export function reducer(state: AppState, action: Action): AppState { - switch (action.type) { - case "SYNC_SERVER_READ_MODEL": { - const projects = mapProjectsFromReadModel( - action.readModel.projects.filter((project) => project.deletedAt === null), - state.projects, - ); - const existingThreadById = new Map( - state.threads.map((thread) => [thread.id, thread] as const), - ); - const defaultTerminal = createDefaultThreadTerminalState(); - const threads = action.readModel.threads - .filter((thread) => thread.deletedAt === null) - .map((thread) => { - const existing = existingThreadById.get(thread.id); - const terminalSlice: ThreadTerminalState = existing - ? threadTerminalSlice(existing) - : defaultTerminal; - - return normalizeThreadTerminals({ - id: thread.id, - codexThreadId: thread.session?.providerThreadId ?? null, - projectId: thread.projectId, - title: thread.title, - model: resolveModelSlug(thread.model), - ...terminalSlice, - session: thread.session - ? { - sessionId: - thread.session.providerSessionId ?? - ProviderSessionId.makeUnsafe(`thread:${thread.id}`), - provider: toLegacyProvider(thread.session.providerName), - status: toLegacySessionStatus(thread.session.status), - orchestrationStatus: thread.session.status, - threadId: thread.session.providerThreadId, - activeTurnId: thread.session.activeTurnId ?? undefined, - createdAt: thread.session.updatedAt, - updatedAt: thread.session.updatedAt, - ...(thread.session.lastError ? { lastError: thread.session.lastError } : {}), - } - : null, - messages: thread.messages.map((message) => { - const attachments = message.attachments?.map((attachment) => ({ - type: "image" as const, - id: attachment.id, - name: attachment.name, - mimeType: attachment.mimeType, - sizeBytes: attachment.sizeBytes, - previewUrl: toAttachmentPreviewUrl(attachmentPreviewRoutePath(attachment.id)), - })); - const normalizedMessage: ChatMessage = { - id: message.id, - role: message.role, - text: message.text, - createdAt: message.createdAt, - streaming: message.streaming, - ...(message.streaming ? {} : { completedAt: message.updatedAt }), - ...(attachments && attachments.length > 0 ? { attachments } : {}), - }; - return normalizedMessage; - }), - error: thread.session?.lastError ?? null, - createdAt: thread.createdAt, - latestTurn: thread.latestTurn, - lastVisitedAt: existing?.lastVisitedAt ?? thread.updatedAt, - branch: thread.branch, - worktreePath: thread.worktreePath, - turnDiffSummaries: thread.checkpoints.map((checkpoint) => ({ - turnId: checkpoint.turnId, - completedAt: checkpoint.completedAt, - status: checkpoint.status, - assistantMessageId: checkpoint.assistantMessageId ?? undefined, - checkpointTurnCount: checkpoint.checkpointTurnCount, - checkpointRef: checkpoint.checkpointRef, - files: checkpoint.files.map((file) => ({ ...file })), - })), - activities: thread.activities.map((activity) => ({ ...activity })), - }); - }); return { - ...state, - projects, - threads, - threadsHydrated: true, - }; - } - - case "HYDRATE_THREAD_TERMINALS": - return { - ...state, - threads: updateThread(state.threads, action.threadId, (thread) => - applyTerminalState(thread, normalizeThreadTerminalState(action.terminalState)), - ), - }; - - case "MARK_THREAD_VISITED": { - const visitedAt = action.visitedAt ?? new Date().toISOString(); - const visitedAtMs = Date.parse(visitedAt); - return { - ...state, - threads: updateThread(state.threads, action.threadId, (thread) => { - const previousVisitedAtMs = thread.lastVisitedAt ? Date.parse(thread.lastVisitedAt) : NaN; - if ( - Number.isFinite(previousVisitedAtMs) && - Number.isFinite(visitedAtMs) && - previousVisitedAtMs >= visitedAtMs - ) { - return thread; - } - return { - ...thread, - lastVisitedAt: visitedAt, - }; - }), - }; - } - - case "MARK_THREAD_UNREAD": { - return { - ...state, - threads: updateThread(state.threads, action.threadId, (thread) => { - if (!thread.latestTurn?.completedAt) { - return thread; - } - const latestTurnCompletedAtMs = Date.parse(thread.latestTurn.completedAt); - if (Number.isNaN(latestTurnCompletedAtMs)) { - return thread; - } - const unreadVisitedAt = new Date(latestTurnCompletedAtMs - 1).toISOString(); - if (thread.lastVisitedAt === unreadVisitedAt) { - return thread; - } - return { - ...thread, - lastVisitedAt: unreadVisitedAt, - }; - }), - }; - } - - case "TOGGLE_PROJECT": - return { - ...state, - projects: state.projects.map((p) => - p.id === action.projectId ? { ...p, expanded: !p.expanded } : p, - ), - }; - - case "SET_THREAD_TERMINAL_ACTIVITY": - return { - ...state, - threads: updateThread(state.threads, action.threadId, (thread) => - applyTerminalState( - thread, - reduceThreadTerminalState(threadTerminalSlice(thread), { - type: "set-activity", - terminalId: action.terminalId, - hasRunningSubprocess: action.hasRunningSubprocess, - }), - ), - ), - }; - - case "SET_PROJECT_EXPANDED": - return { - ...state, - projects: state.projects.map((p) => - p.id === action.projectId ? { ...p, expanded: action.expanded } : p, - ), - }; - - case "TOGGLE_THREAD_TERMINAL": - return { - ...state, - threads: updateThread(state.threads, action.threadId, (thread) => - applyTerminalState( - thread, - reduceThreadTerminalState(threadTerminalSlice(thread), { - type: "set-open", - open: !thread.terminalOpen, - }), - ), - ), - }; - - case "SET_THREAD_TERMINAL_OPEN": - return { - ...state, - threads: updateThread(state.threads, action.threadId, (thread) => - applyTerminalState( - thread, - reduceThreadTerminalState(threadTerminalSlice(thread), { - type: "set-open", - open: action.open, - }), - ), - ), - }; - - case "SET_THREAD_TERMINAL_HEIGHT": - return { - ...state, - threads: updateThread(state.threads, action.threadId, (thread) => - applyTerminalState( - thread, - reduceThreadTerminalState(threadTerminalSlice(thread), { - type: "set-height", - height: action.height, - }), - ), - ), - }; - - case "SPLIT_THREAD_TERMINAL": - return { - ...state, - threads: updateThread(state.threads, action.threadId, (thread) => - applyTerminalState( - thread, - reduceThreadTerminalState(threadTerminalSlice(thread), { - type: "split", - terminalId: action.terminalId, - }), - ), - ), - }; - - case "NEW_THREAD_TERMINAL": - return { - ...state, - threads: updateThread(state.threads, action.threadId, (thread) => - applyTerminalState( - thread, - reduceThreadTerminalState(threadTerminalSlice(thread), { - type: "new", - terminalId: action.terminalId, - }), - ), - ), - }; - - case "SET_THREAD_ACTIVE_TERMINAL": - return { - ...state, - threads: updateThread(state.threads, action.threadId, (thread) => - applyTerminalState( - thread, - reduceThreadTerminalState(threadTerminalSlice(thread), { - type: "set-active", - terminalId: action.terminalId, - }), - ), - ), - }; - - case "CLOSE_THREAD_TERMINAL": - return { - ...state, - threads: updateThread(state.threads, action.threadId, (thread) => - applyTerminalState( - thread, - reduceThreadTerminalState(threadTerminalSlice(thread), { - type: "close", - terminalId: action.terminalId, - }), - ), - ), - }; - - case "SET_ERROR": - return { - ...state, - threads: updateThread(state.threads, action.threadId, (t) => ({ - ...t, - error: action.error, - })), - }; - - case "SET_THREAD_BRANCH": { - return { - ...state, - threads: updateThread(state.threads, action.threadId, (t) => { - // When the effective cwd changes (worktreePath differs), the old - // session is no longer valid — clear it so ensureSession creates a - // new one with the correct cwd on the next message. - const cwdChanged = t.worktreePath !== action.worktreePath; - return { - ...t, - branch: action.branch, - worktreePath: action.worktreePath, - ...(cwdChanged ? { session: null } : {}), + id: thread.id, + codexThreadId: thread.session?.providerThreadId ?? null, + projectId: thread.projectId, + title: thread.title, + model: resolveModelSlug(thread.model), + session: thread.session + ? { + sessionId: + thread.session.providerSessionId ?? + ProviderSessionId.makeUnsafe(`thread:${thread.id}`), + provider: toLegacyProvider(thread.session.providerName), + status: toLegacySessionStatus(thread.session.status), + orchestrationStatus: thread.session.status, + threadId: thread.session.providerThreadId, + activeTurnId: thread.session.activeTurnId ?? undefined, + createdAt: thread.session.updatedAt, + updatedAt: thread.session.updatedAt, + ...(thread.session.lastError ? { lastError: thread.session.lastError } : {}), + } + : null, + messages: thread.messages.map((message) => { + const attachments = message.attachments?.map((attachment) => ({ + type: "image" as const, + id: attachment.id, + name: attachment.name, + mimeType: attachment.mimeType, + sizeBytes: attachment.sizeBytes, + previewUrl: toAttachmentPreviewUrl(attachmentPreviewRoutePath(attachment.id)), + })); + const normalizedMessage: ChatMessage = { + id: message.id, + role: message.role, + text: message.text, + createdAt: message.createdAt, + streaming: message.streaming, + ...(message.streaming ? {} : { completedAt: message.updatedAt }), + ...(attachments && attachments.length > 0 ? { attachments } : {}), }; return normalizedMessage; }), @@ -593,7 +283,7 @@ export function reducer(state: AppState, action: Action): AppState { files: checkpoint.files.map((file) => ({ ...file })), })), activities: thread.activities.map((activity) => ({ ...activity })), - }); + }; }); return { ...state, @@ -603,9 +293,13 @@ export function reducer(state: AppState, action: Action): AppState { }; } -function markThreadVisitedState(state: AppState, threadId: ThreadId, visitedAt?: string): AppState { - const nextVisitedAt = visitedAt ?? new Date().toISOString(); - const visitedAtMs = Date.parse(nextVisitedAt); +export function markThreadVisited( + state: AppState, + threadId: ThreadId, + visitedAt?: string, +): AppState { + const at = visitedAt ?? new Date().toISOString(); + const visitedAtMs = Date.parse(at); return { ...state, threads: updateThread(state.threads, threadId, (thread) => { @@ -617,269 +311,55 @@ function markThreadVisitedState(state: AppState, threadId: ThreadId, visitedAt?: ) { return thread; } - return { - ...thread, - lastVisitedAt: nextVisitedAt, - }; + return { ...thread, lastVisitedAt: at }; }), }; } -function markThreadUnreadState(state: AppState, threadId: ThreadId): AppState { +export function markThreadUnread(state: AppState, threadId: ThreadId): AppState { return { ...state, threads: updateThread(state.threads, threadId, (thread) => { - if (!thread.latestTurn?.completedAt) { - return thread; - } + if (!thread.latestTurn?.completedAt) return thread; const latestTurnCompletedAtMs = Date.parse(thread.latestTurn.completedAt); - if (Number.isNaN(latestTurnCompletedAtMs)) { - return thread; - } + if (Number.isNaN(latestTurnCompletedAtMs)) return thread; const unreadVisitedAt = new Date(latestTurnCompletedAtMs - 1).toISOString(); - if (thread.lastVisitedAt === unreadVisitedAt) { - return thread; - } - return { - ...thread, - lastVisitedAt: unreadVisitedAt, - }; + if (thread.lastVisitedAt === unreadVisitedAt) return thread; + return { ...thread, lastVisitedAt: unreadVisitedAt }; }), }; } -function toggleProjectState(state: AppState, projectId: Project["id"]): AppState { +export function toggleProject(state: AppState, projectId: Project["id"]): AppState { return { ...state, - projects: state.projects.map((project) => - project.id === projectId ? { ...project, expanded: !project.expanded } : project, + projects: state.projects.map((p) => + p.id === projectId ? { ...p, expanded: !p.expanded } : p, ), }; } -function setThreadTerminalActivityState( - state: AppState, - threadId: ThreadId, - terminalId: string, - hasRunningSubprocess: boolean, -): AppState { - return { - ...state, - threads: updateThread(state.threads, threadId, (thread) => - setThreadTerminalActivity(thread, terminalId, hasRunningSubprocess), - ), - }; -} - -function setProjectExpandedState( +export function setProjectExpanded( state: AppState, projectId: Project["id"], expanded: boolean, ): AppState { return { ...state, - projects: state.projects.map((project) => - project.id === projectId ? { ...project, expanded } : project, + projects: state.projects.map((p) => + p.id === projectId ? { ...p, expanded } : p, ), }; } -function toggleThreadTerminalState(state: AppState, threadId: ThreadId): AppState { +export function setError(state: AppState, threadId: ThreadId, error: string | null): AppState { return { ...state, - threads: updateThread(state.threads, threadId, (thread) => ({ - ...thread, - terminalOpen: !thread.terminalOpen, - })), + threads: updateThread(state.threads, threadId, (t) => ({ ...t, error })), }; } -function setThreadTerminalOpenState(state: AppState, threadId: ThreadId, open: boolean): AppState { - return { - ...state, - threads: updateThread(state.threads, threadId, (thread) => ({ - ...thread, - terminalOpen: open, - })), - }; -} - -function setThreadTerminalHeightState( - state: AppState, - threadId: ThreadId, - height: number, -): AppState { - return { - ...state, - threads: updateThread(state.threads, threadId, (thread) => ({ - ...thread, - terminalHeight: height, - })), - }; -} - -function splitThreadTerminalState( - state: AppState, - threadId: ThreadId, - terminalId: string, -): AppState { - return { - ...state, - threads: updateThread(state.threads, threadId, (thread) => { - const normalizedThread = normalizeThreadTerminals(thread); - const isNewTerminal = !normalizedThread.terminalIds.includes(terminalId); - if (isNewTerminal && normalizedThread.terminalIds.length >= MAX_THREAD_TERMINAL_COUNT) { - return normalizedThread; - } - const terminalIds = normalizedThread.terminalIds.includes(terminalId) - ? normalizedThread.terminalIds - : [...normalizedThread.terminalIds, terminalId]; - const terminalGroups = normalizedThread.terminalGroups.map((group) => ({ - ...group, - terminalIds: [...group.terminalIds], - })); - let activeGroupIndex = terminalGroups.findIndex( - (group) => group.id === normalizedThread.activeTerminalGroupId, - ); - if (activeGroupIndex < 0) { - activeGroupIndex = findGroupIndexByTerminalId(terminalGroups, normalizedThread.activeTerminalId); - } - if (activeGroupIndex < 0) { - terminalGroups.push({ - id: fallbackGroupId(normalizedThread.activeTerminalId), - terminalIds: [normalizedThread.activeTerminalId], - }); - activeGroupIndex = terminalGroups.length - 1; - } - - const existingGroupIndex = findGroupIndexByTerminalId(terminalGroups, terminalId); - if (existingGroupIndex >= 0) { - terminalGroups[existingGroupIndex]!.terminalIds = terminalGroups[existingGroupIndex]!.terminalIds.filter( - (id) => id !== terminalId, - ); - if (terminalGroups[existingGroupIndex]!.terminalIds.length === 0) { - terminalGroups.splice(existingGroupIndex, 1); - if (existingGroupIndex < activeGroupIndex) { - activeGroupIndex -= 1; - } - } - } - - const destinationGroup = terminalGroups[activeGroupIndex]; - if (!destinationGroup) { - return normalizedThread; - } - if (!destinationGroup.terminalIds.includes(terminalId)) { - const anchorIndex = destinationGroup.terminalIds.indexOf(normalizedThread.activeTerminalId); - if (anchorIndex >= 0) { - destinationGroup.terminalIds.splice(anchorIndex + 1, 0, terminalId); - } else { - destinationGroup.terminalIds.push(terminalId); - } - } - return normalizeThreadTerminals({ - ...normalizedThread, - terminalIds, - activeTerminalId: terminalId, - activeTerminalGroupId: destinationGroup.id, - terminalGroups, - }); - }), - }; -} - -function newThreadTerminalState( - state: AppState, - threadId: ThreadId, - terminalId: string, -): AppState { - return { - ...state, - threads: updateThread(state.threads, threadId, (thread) => { - const normalizedThread = normalizeThreadTerminals(thread); - const isNewTerminal = !normalizedThread.terminalIds.includes(terminalId); - if (isNewTerminal && normalizedThread.terminalIds.length >= MAX_THREAD_TERMINAL_COUNT) { - return normalizedThread; - } - const terminalIds = normalizedThread.terminalIds.includes(terminalId) - ? normalizedThread.terminalIds - : [...normalizedThread.terminalIds, terminalId]; - const terminalGroups = normalizedThread.terminalGroups - .map((group) => ({ - ...group, - terminalIds: group.terminalIds.filter((id) => id !== terminalId), - })) - .filter((group) => group.terminalIds.length > 0); - const nextGroupId = fallbackGroupId(terminalId); - terminalGroups.push({ id: nextGroupId, terminalIds: [terminalId] }); - - return normalizeThreadTerminals({ - ...normalizedThread, - terminalIds, - activeTerminalId: terminalId, - activeTerminalGroupId: nextGroupId, - terminalGroups, - }); - }), - }; -} - -function setThreadActiveTerminalState( - state: AppState, - threadId: ThreadId, - terminalId: string, -): AppState { - return { - ...state, - threads: updateThread(state.threads, threadId, (thread) => { - const normalizedThread = normalizeThreadTerminals(thread); - if (!normalizedThread.terminalIds.includes(terminalId)) { - return thread; - } - const nextActiveGroupIndex = findGroupIndexByTerminalId( - normalizedThread.terminalGroups, - terminalId, - ); - const activeTerminalGroupId = - nextActiveGroupIndex >= 0 - ? (normalizedThread.terminalGroups[nextActiveGroupIndex]?.id ?? - normalizedThread.activeTerminalGroupId) - : normalizedThread.activeTerminalGroupId; - return normalizeThreadTerminals({ - ...normalizedThread, - activeTerminalId: terminalId, - activeTerminalGroupId, - }); - }), - }; -} - -function closeThreadTerminalState( - state: AppState, - threadId: ThreadId, - terminalId: string, -): AppState { - return { - ...state, - threads: updateThread(state.threads, threadId, (thread) => closeThreadTerminal(thread, terminalId)), - }; -} - -function setThreadErrorState( - state: AppState, - threadId: ThreadId, - error: string | null, -): AppState { - return { - ...state, - threads: updateThread(state.threads, threadId, (thread) => ({ - ...thread, - error, - })), - }; -} - -function setThreadBranchState( +export function setThreadBranch( state: AppState, threadId: ThreadId, branch: string | null, @@ -887,13 +367,10 @@ function setThreadBranchState( ): AppState { return { ...state, - threads: updateThread(state.threads, threadId, (thread) => { - // When the effective cwd changes (worktreePath differs), the old - // session is no longer valid — clear it so ensureSession creates a - // new one with the correct cwd on the next message. - const cwdChanged = thread.worktreePath !== worktreePath; + threads: updateThread(state.threads, threadId, (t) => { + const cwdChanged = t.worktreePath !== worktreePath; return { - ...thread, + ...t, branch, worktreePath, ...(cwdChanged ? { session: null } : {}), @@ -902,71 +379,68 @@ function setThreadBranchState( }; } -function setRuntimeModeState(state: AppState, mode: RuntimeMode): AppState { - return { - ...state, - runtimeMode: mode, - }; +export function setRuntimeMode(state: AppState, mode: RuntimeMode): AppState { + return { ...state, runtimeMode: mode }; } -function toAppState(store: AppStore): AppState { - return { - projects: store.projects, - threads: store.threads, - threadsHydrated: store.threadsHydrated, - runtimeMode: store.runtimeMode, - }; +// ── Zustand store ──────────────────────────────────────────────────── + +interface AppStore extends AppState { + syncServerReadModel: (readModel: OrchestrationReadModel) => void; + markThreadVisited: (threadId: ThreadId, visitedAt?: string) => void; + markThreadUnread: (threadId: ThreadId) => void; + toggleProject: (projectId: Project["id"]) => void; + setProjectExpanded: (projectId: Project["id"], expanded: boolean) => void; + setError: (threadId: ThreadId, error: string | null) => void; + setThreadBranch: ( + threadId: ThreadId, + branch: string | null, + worktreePath: string | null, + ) => void; + setRuntimeMode: (mode: RuntimeMode) => void; } -export const useStore = create((set) => { - const setAppState = (updater: (state: AppState) => AppState): void => { - set((store) => { - const currentState = toAppState(store); - const nextState = updater(currentState); - if (nextState === currentState) { - return store; - } - persistState(nextState); - return nextState; - }); - }; +export const useAppStore = create((set, _get) => ({ + ...readPersistedState(), + syncServerReadModel: (readModel) => + set((state) => syncServerReadModel(state, readModel)), + markThreadVisited: (threadId, visitedAt) => + set((state) => markThreadVisited(state, threadId, visitedAt)), + markThreadUnread: (threadId) => + set((state) => markThreadUnread(state, threadId)), + toggleProject: (projectId) => + set((state) => toggleProject(state, projectId)), + setProjectExpanded: (projectId, expanded) => + set((state) => setProjectExpanded(state, projectId, expanded)), + setError: (threadId, error) => + set((state) => setError(state, threadId, error)), + setThreadBranch: (threadId, branch, worktreePath) => + set((state) => setThreadBranch(state, threadId, branch, worktreePath)), + setRuntimeMode: (mode) => + set((state) => setRuntimeMode(state, mode)), +})); + +// Persist on every state change (only runtimeMode + expandedProjectCwds) +useAppStore.subscribe((state) => persistState(state)); - return { - ...readPersistedState(), - syncServerReadModel: (readModel) => - setAppState((state) => syncServerReadModelState(state, readModel)), - markThreadVisited: (threadId, visitedAt) => - setAppState((state) => markThreadVisitedState(state, threadId, visitedAt)), - markThreadUnread: (threadId) => setAppState((state) => markThreadUnreadState(state, threadId)), - toggleProject: (projectId) => setAppState((state) => toggleProjectState(state, projectId)), - setThreadTerminalActivity: (threadId, terminalId, hasRunningSubprocess) => - setAppState((state) => - setThreadTerminalActivityState(state, threadId, terminalId, hasRunningSubprocess), - ), - setProjectExpanded: (projectId, expanded) => - setAppState((state) => setProjectExpandedState(state, projectId, expanded)), - toggleThreadTerminal: (threadId) => - setAppState((state) => toggleThreadTerminalState(state, threadId)), - setThreadTerminalOpen: (threadId, open) => - setAppState((state) => setThreadTerminalOpenState(state, threadId, open)), - setThreadTerminalHeight: (threadId, height) => - setAppState((state) => setThreadTerminalHeightState(state, threadId, height)), - splitThreadTerminal: (threadId, terminalId) => - setAppState((state) => splitThreadTerminalState(state, threadId, terminalId)), - newThreadTerminal: (threadId, terminalId) => - setAppState((state) => newThreadTerminalState(state, threadId, terminalId)), - setThreadActiveTerminal: (threadId, terminalId) => - setAppState((state) => setThreadActiveTerminalState(state, threadId, terminalId)), - closeThreadTerminal: (threadId, terminalId) => - setAppState((state) => closeThreadTerminalState(state, threadId, terminalId)), - setThreadError: (threadId, error) => - setAppState((state) => setThreadErrorState(state, threadId, error)), - setThreadBranch: (threadId, branch, worktreePath) => - setAppState((state) => setThreadBranchState(state, threadId, branch, worktreePath)), - setRuntimeMode: (mode) => setAppState((state) => setRuntimeModeState(state, mode)), - }; -}); +// ── useStore (state + store API for call sites that want both) ──────────────── export function StoreProvider({ children }: { children: ReactNode }) { - return children; + useEffect(() => { + persistState(useAppStore.getState()); + }, []); + return createElement(Fragment, null, children); +} + +export function useStore() { + const state = useAppStore((s) => ({ + projects: s.projects, + threads: s.threads, + threadsHydrated: s.threadsHydrated, + runtimeMode: s.runtimeMode, + })); + return { + state, + dispatch: useAppStore.getState(), + }; } diff --git a/apps/web/src/terminalStateStore.ts b/apps/web/src/terminalStateStore.ts new file mode 100644 index 0000000000000000000000000000000000000000..e630821b1ebfb7e6387772a7a08361c388e186cb --- /dev/null +++ b/apps/web/src/terminalStateStore.ts @@ -0,0 +1,110 @@ +/** + * Single Zustand store for terminal UI state keyed by threadId. Used by both + * persisted (server) threads and draft threads; threadId is stable for both. + */ + +import type { ThreadId } from "@t3tools/contracts"; +import { create } from "zustand"; +import { createJSONStorage, persist } from "zustand/middleware"; +import { + createDefaultThreadTerminalState, + getDefaultThreadTerminalState, + reduceThreadTerminalState, + type ThreadTerminalAction, + type ThreadTerminalState, +} from "./threadTerminalState"; + +export const TERMINAL_STATE_STORAGE_KEY = "t3code:terminal-state:v1"; + +function applyTerminalAction( + state: Record, + threadId: ThreadId, + action: ThreadTerminalAction, +): Record { + const current = state[threadId] ?? createDefaultThreadTerminalState(); + const next = reduceThreadTerminalState(current, action); + if (next === current) { + return state; + } + return { + ...state, + [threadId]: next, + }; +} + +interface TerminalStateStoreState { + terminalStateByThreadId: Record; + getTerminalState: (threadId: ThreadId) => ThreadTerminalState; + setTerminalOpen: (threadId: ThreadId, open: boolean) => void; + setTerminalHeight: (threadId: ThreadId, height: number) => void; + splitTerminal: (threadId: ThreadId, terminalId: string) => void; + newTerminal: (threadId: ThreadId, terminalId: string) => void; + setActiveTerminal: (threadId: ThreadId, terminalId: string) => void; + closeTerminal: (threadId: ThreadId, terminalId: string) => void; + setTerminalActivity: ( + threadId: ThreadId, + terminalId: string, + hasRunningSubprocess: boolean, + ) => void; + clearTerminalState: (threadId: ThreadId) => void; +} + +export const useTerminalStateStore = create()( + persist( + (set, get) => { + const updateTerminal = (threadId: ThreadId, action: ThreadTerminalAction) => { + if (threadId.length === 0) return; + set((state) => ({ + terminalStateByThreadId: applyTerminalAction( + state.terminalStateByThreadId, + threadId, + action, + ), + })); + }; + return { + terminalStateByThreadId: {}, + getTerminalState: (threadId) => { + if (threadId.length === 0) { + return getDefaultThreadTerminalState(); + } + const state = get().terminalStateByThreadId[threadId]; + return state ?? getDefaultThreadTerminalState(); + }, + setTerminalOpen: (threadId, open) => updateTerminal(threadId, { type: "set-open", open }), + setTerminalHeight: (threadId, height) => + updateTerminal(threadId, { type: "set-height", height }), + splitTerminal: (threadId, terminalId) => + updateTerminal(threadId, { type: "split", terminalId }), + newTerminal: (threadId, terminalId) => + updateTerminal(threadId, { type: "new", terminalId }), + setActiveTerminal: (threadId, terminalId) => + updateTerminal(threadId, { type: "set-active", terminalId }), + closeTerminal: (threadId, terminalId) => + updateTerminal(threadId, { type: "close", terminalId }), + setTerminalActivity: (threadId, terminalId, hasRunningSubprocess) => + updateTerminal(threadId, { type: "set-activity", terminalId, hasRunningSubprocess }), + clearTerminalState: (threadId) => { + if (threadId.length === 0) return; + set((state) => { + if (state.terminalStateByThreadId[threadId] === undefined) { + return state; + } + const { [threadId]: _removed, ...rest } = state.terminalStateByThreadId; + return { + terminalStateByThreadId: rest as Record, + }; + }); + }, + }; + }, + { + name: TERMINAL_STATE_STORAGE_KEY, + version: 1, + storage: createJSONStorage(() => localStorage), + partialize: (state) => ({ + terminalStateByThreadId: state.terminalStateByThreadId, + }), + }, + ), +); diff --git a/apps/web/src/types.ts b/apps/web/src/types.ts index 0ae07e20c4164478adef5ae1266109b3427a6555..214b27cb4f8deed191a7ec62e70d7bf7f5742b43 100644 --- a/apps/web/src/types.ts +++ b/apps/web/src/types.ts @@ -79,13 +79,6 @@ export interface Thread { projectId: ProjectId; title: string; model: string; - terminalOpen: boolean; - terminalHeight: number; - terminalIds: string[]; - runningTerminalIds: string[]; - activeTerminalId: string; - terminalGroups: ThreadTerminalGroup[]; - activeTerminalGroupId: string; session: ThreadSession | null; messages: ChatMessage[]; error: string | null; diff --git a/apps/web/src/worktreeCleanup.test.ts b/apps/web/src/worktreeCleanup.test.ts index 4b4d90227a5969468d0d441802c9e31314240e52..a73b7cd4eb524834b1f009cda98660346b0fe2f8 100644 --- a/apps/web/src/worktreeCleanup.test.ts +++ b/apps/web/src/worktreeCleanup.test.ts @@ -1,7 +1,7 @@ import { ProjectId, ThreadId } from "@t3tools/contracts"; import { describe, expect, it } from "vitest"; -import { DEFAULT_THREAD_TERMINAL_HEIGHT, DEFAULT_THREAD_TERMINAL_ID, type Thread } from "./types"; +import type { Thread } from "./types"; import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "./worktreeCleanup"; function makeThread(overrides: Partial = {}): Thread { @@ -11,18 +11,6 @@ function makeThread(overrides: Partial = {}): Thread { projectId: ProjectId.makeUnsafe("project-1"), title: "Thread", model: "gpt-5.3-codex", - terminalOpen: false, - terminalHeight: DEFAULT_THREAD_TERMINAL_HEIGHT, - terminalIds: [DEFAULT_THREAD_TERMINAL_ID], - runningTerminalIds: [], - activeTerminalId: DEFAULT_THREAD_TERMINAL_ID, - terminalGroups: [ - { - id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, - terminalIds: [DEFAULT_THREAD_TERMINAL_ID], - }, - ], - activeTerminalGroupId: `group-${DEFAULT_THREAD_TERMINAL_ID}`, session: null, messages: [], turnDiffSummaries: [], -- 2.54.0