From f6fd760e4eb9e18dd7b89cf45f478835d20145f8 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 2 Mar 2026 00:54:14 -0800 Subject: [PATCH] Enable terminals for local draft threads Adds local terminal state management for draft threads before they're promoted to server threads. Terminal operations (open, split, create, close, activate, resize) now work on draft threads and hydrate into the server thread state when the draft is promoted. Co-Authored-By: Claude Opus 4.5 --- apps/web/src/components/ChatView.tsx | 320 ++++++++++++++--- apps/web/src/draftThreadTerminalState.test.ts | 87 +++++ apps/web/src/draftThreadTerminalState.ts | 332 ++++++++++++++++++ apps/web/src/store.test.ts | 39 ++ apps/web/src/store.ts | 306 +++++++++++++--- 5 files changed, 990 insertions(+), 94 deletions(-) create mode 100644 apps/web/src/draftThreadTerminalState.test.ts create mode 100644 apps/web/src/draftThreadTerminalState.ts diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 338dd72402266e1023ae53ba771a1982a4331aff..03af546d5611f08d79fe3c866b211207bb2e9487 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -61,6 +61,11 @@ import { type Thread, type TurnDiffSummary, } from "../types"; +import { + createDefaultDraftThreadTerminalState, + reduceDraftThreadTerminalState, + type DraftThreadTerminalState, +} from "../draftThreadTerminalState"; import { basenameOfPath, getVscodeIconUrlForEntry } from "../vscode-icons"; import { useTheme } from "../hooks/useTheme"; import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; @@ -204,6 +209,7 @@ function buildExpandedImagePreview( function buildLocalDraftThread( threadId: ThreadId, draftThread: DraftThreadState, + draftTerminalState: DraftThreadTerminalState, fallbackModel: string, error: string | null, ): Thread { @@ -213,18 +219,13 @@ function buildLocalDraftThread( projectId: draftThread.projectId, title: "New thread", model: fallbackModel, - terminalOpen: false, - terminalHeight: 280, - 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}`, + 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, @@ -238,6 +239,46 @@ function buildLocalDraftThread( }; } +function threadTerminalsMatchState(thread: Thread, terminalState: DraftThreadTerminalState): 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; @@ -506,6 +547,9 @@ export default function ChatView({ threadId }: ChatViewProps) { const [localDraftErrorsByThreadId, setLocalDraftErrorsByThreadId] = useState< Record >({}); + const [draftTerminalStateByThreadId, setDraftTerminalStateByThreadId] = useState< + Record + >({}); const [sendPhase, setSendPhase] = useState("idle"); const [isConnecting, _setIsConnecting] = useState(false); const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false); @@ -541,6 +585,26 @@ export default function ChatView({ threadId }: ChatViewProps) { setMessagesScrollElement(element); }, []); + const updateDraftTerminalState = useCallback( + ( + targetThreadId: ThreadId, + updater: (state: DraftThreadTerminalState) => DraftThreadTerminalState, + ) => { + setDraftTerminalStateByThreadId((existing) => { + const previous = existing[targetThreadId] ?? createDefaultDraftThreadTerminalState(); + const next = updater(previous); + if (next === previous) { + return existing; + } + return { + ...existing, + [targetThreadId]: next, + }; + }); + }, + [], + ); + const setPrompt = useCallback( (nextPrompt: string) => { setComposerDraftPrompt(threadId, nextPrompt); @@ -573,17 +637,19 @@ export default function ChatView({ threadId }: ChatViewProps) { ); const localDraftError = localDraftErrorsByThreadId[threadId] ?? null; + const localDraftTerminalState = draftTerminalStateByThreadId[threadId]; const localDraftThread = useMemo( () => draftThread ? buildLocalDraftThread( threadId, draftThread, + localDraftTerminalState ?? createDefaultDraftThreadTerminalState(), fallbackDraftProject?.model ?? DEFAULT_MODEL, localDraftError, ) : undefined, - [draftThread, fallbackDraftProject?.model, localDraftError, threadId], + [draftThread, fallbackDraftProject?.model, localDraftError, localDraftTerminalState, threadId], ); const activeThread = serverThread ?? localDraftThread; const isServerThread = serverThread !== undefined; @@ -614,6 +680,27 @@ export default function ChatView({ threadId }: ChatViewProps) { }); }, [serverThread, threadId]); + useEffect(() => { + if (!serverThread || !localDraftTerminalState) { + return; + } + if (!threadTerminalsMatchState(serverThread, localDraftTerminalState)) { + dispatch({ + type: "HYDRATE_THREAD_TERMINALS", + threadId: serverThread.id, + terminalState: localDraftTerminalState, + }); + return; + } + setDraftTerminalStateByThreadId((existing) => { + if (!(threadId in existing)) { + return existing; + } + const { [threadId]: _removed, ...rest } = existing; + return rest as Record; + }); + }, [dispatch, localDraftTerminalState, serverThread, threadId]); + useEffect(() => { if (!activeThread?.id) return; if (!latestTurnSettled) return; @@ -981,6 +1068,29 @@ export default function ChatView({ threadId }: ChatViewProps) { ); const hasReachedTerminalLimit = (activeThread?.terminalIds.length ?? 0) >= 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, + }); + return; + } + setLocalDraftErrorsByThreadId((existing) => { + if ((existing[targetThreadId] ?? null) === error) { + return existing; + } + return { + ...existing, + [targetThreadId]: error, + }; + }); + }, + [dispatch, state.threads], + ); const focusComposer = useCallback(() => { const textarea = textareaRef.current; @@ -994,27 +1104,108 @@ export default function ChatView({ threadId }: ChatViewProps) { focusComposer(); }); }, [focusComposer]); + const setTerminalOpen = useCallback( + (open: boolean) => { + if (!activeThreadId) return; + if (isLocalDraftThread) { + updateDraftTerminalState(activeThreadId, (draftState) => + reduceDraftThreadTerminalState(draftState, { + type: "set-open", + open, + }), + ); + return; + } + dispatch({ + type: "SET_THREAD_TERMINAL_OPEN", + threadId: activeThreadId, + open, + }); + }, + [activeThreadId, dispatch, isLocalDraftThread, updateDraftTerminalState], + ); + const setTerminalHeight = useCallback( + (height: number) => { + if (!activeThreadId) return; + if (isLocalDraftThread) { + updateDraftTerminalState(activeThreadId, (draftState) => + reduceDraftThreadTerminalState(draftState, { + type: "set-height", + height, + }), + ); + return; + } + dispatch({ + type: "SET_THREAD_TERMINAL_HEIGHT", + threadId: activeThreadId, + height, + }); + }, + [activeThreadId, dispatch, isLocalDraftThread, updateDraftTerminalState], + ); const toggleTerminalVisibility = useCallback(() => { if (!activeThreadId) return; - setThreadTerminalOpen(activeThreadId, !activeThread?.terminalOpen); - }, [activeThread?.terminalOpen, activeThreadId, setThreadTerminalOpen]); + setTerminalOpen(!activeThread?.terminalOpen); + }, [activeThread?.terminalOpen, activeThreadId, setTerminalOpen]); const splitTerminal = useCallback(() => { if (!activeThreadId || hasReachedTerminalLimit) return; - splitThreadTerminalAction(activeThreadId, `terminal-${crypto.randomUUID()}`); + const terminalId = `terminal-${crypto.randomUUID()}`; + if (isLocalDraftThread) { + updateDraftTerminalState(activeThreadId, (draftState) => + reduceDraftThreadTerminalState(draftState, { + type: "split", + terminalId, + }), + ); + } else { + dispatch({ + type: "SPLIT_THREAD_TERMINAL", + threadId: activeThreadId, + terminalId, + }); + } setTerminalFocusRequestId((value) => value + 1); - }, [activeThreadId, hasReachedTerminalLimit, splitThreadTerminalAction]); + }, [activeThreadId, dispatch, hasReachedTerminalLimit, isLocalDraftThread, updateDraftTerminalState]); const createNewTerminal = useCallback(() => { if (!activeThreadId || hasReachedTerminalLimit) return; - newThreadTerminalAction(activeThreadId, `terminal-${crypto.randomUUID()}`); + const terminalId = `terminal-${crypto.randomUUID()}`; + if (isLocalDraftThread) { + updateDraftTerminalState(activeThreadId, (draftState) => + reduceDraftThreadTerminalState(draftState, { + type: "new", + terminalId, + }), + ); + } else { + dispatch({ + type: "NEW_THREAD_TERMINAL", + threadId: activeThreadId, + terminalId, + }); + } setTerminalFocusRequestId((value) => value + 1); - }, [activeThreadId, hasReachedTerminalLimit, newThreadTerminalAction]); + }, [activeThreadId, dispatch, hasReachedTerminalLimit, isLocalDraftThread, updateDraftTerminalState]); const activateTerminal = useCallback( (terminalId: string) => { if (!activeThreadId) return; - setThreadActiveTerminal(activeThreadId, terminalId); + if (isLocalDraftThread) { + updateDraftTerminalState(activeThreadId, (draftState) => + reduceDraftThreadTerminalState(draftState, { + type: "set-active", + terminalId, + }), + ); + } else { + dispatch({ + type: "SET_THREAD_ACTIVE_TERMINAL", + threadId: activeThreadId, + terminalId, + }); + } setTerminalFocusRequestId((value) => value + 1); }, - [activeThreadId, setThreadActiveTerminal], + [activeThreadId, dispatch, isLocalDraftThread, updateDraftTerminalState], ); const closeTerminal = useCallback( (terminalId: string) => { @@ -1037,10 +1228,23 @@ export default function ChatView({ threadId }: ChatViewProps) { } else { void fallbackExitWrite(); } - closeThreadTerminalAction(activeThreadId, terminalId); + if (isLocalDraftThread) { + updateDraftTerminalState(activeThreadId, (draftState) => + reduceDraftThreadTerminalState(draftState, { + type: "close", + terminalId, + }), + ); + } else { + dispatch({ + type: "CLOSE_THREAD_TERMINAL", + threadId: activeThreadId, + terminalId, + }); + } setTerminalFocusRequestId((value) => value + 1); }, - [activeThread?.terminalIds.length, activeThreadId, closeThreadTerminalAction], + [activeThread?.terminalIds.length, activeThreadId, dispatch, isLocalDraftThread, updateDraftTerminalState], ); const runProjectScript = useCallback( async ( @@ -1074,11 +1278,37 @@ export default function ChatView({ threadId }: ChatViewProps) { ? `terminal-${crypto.randomUUID()}` : baseTerminalId; - setThreadTerminalOpen(activeThreadId, true); + setTerminalOpen(true); if (shouldCreateNewTerminal) { - newThreadTerminalAction(activeThreadId, targetTerminalId); + if (isLocalDraftThread) { + updateDraftTerminalState(activeThreadId, (draftState) => + reduceDraftThreadTerminalState(draftState, { + type: "new", + terminalId: targetTerminalId, + }), + ); + } else { + dispatch({ + type: "NEW_THREAD_TERMINAL", + threadId: activeThreadId, + terminalId: targetTerminalId, + }); + } } else { - setThreadActiveTerminal(activeThreadId, targetTerminalId); + if (isLocalDraftThread) { + updateDraftTerminalState(activeThreadId, (draftState) => + reduceDraftThreadTerminalState(draftState, { + type: "set-active", + terminalId: targetTerminalId, + }), + ); + } else { + dispatch({ + type: "SET_THREAD_ACTIVE_TERMINAL", + threadId: activeThreadId, + terminalId: targetTerminalId, + }); + } } setTerminalFocusRequestId((value) => value + 1); @@ -1109,7 +1339,7 @@ export default function ChatView({ threadId }: ChatViewProps) { data: `${script.command}\r`, }); } catch (error) { - setThreadErrorAction( + setThreadError( activeThreadId, error instanceof Error ? error.message : `Failed to run script "${script.name}".`, ); @@ -1119,12 +1349,13 @@ export default function ChatView({ threadId }: ChatViewProps) { activeProject, activeThread, activeThreadId, + dispatch, gitCwd, + isLocalDraftThread, isServerThread, - newThreadTerminalAction, - setThreadActiveTerminal, - setThreadErrorAction, - setThreadTerminalOpen, + setTerminalOpen, + setThreadError, + updateDraftTerminalState, ], ); const persistProjectScripts = useCallback( @@ -1633,7 +1864,7 @@ export default function ChatView({ threadId }: ChatViewProps) { event.preventDefault(); event.stopPropagation(); if (!activeThread?.terminalOpen) { - setThreadTerminalOpen(activeThreadId, true); + setTerminalOpen(true); } splitTerminal(); return; @@ -1651,7 +1882,7 @@ export default function ChatView({ threadId }: ChatViewProps) { event.preventDefault(); event.stopPropagation(); if (!activeThread?.terminalOpen) { - setThreadTerminalOpen(activeThreadId, true); + setTerminalOpen(true); } createNewTerminal(); return; @@ -1681,6 +1912,7 @@ export default function ChatView({ threadId }: ChatViewProps) { activeThreadId, closeTerminal, createNewTerminal, + setTerminalOpen, runProjectScript, splitTerminal, keybindings, @@ -1689,26 +1921,6 @@ export default function ChatView({ threadId }: ChatViewProps) { toggleTerminalVisibility, ]); - const setThreadError = useCallback( - (threadId: ThreadId | null, error: string | null) => { - if (!threadId) return; - if (useStore.getState().threads.some((thread) => thread.id === threadId)) { - setThreadErrorAction(threadId, error); - return; - } - setLocalDraftErrorsByThreadId((existing) => { - if ((existing[threadId] ?? null) === error) { - return existing; - } - return { - ...existing, - [threadId]: error, - }; - }); - }, - [setThreadErrorAction], - ); - const addComposerImages = (files: File[]) => { if (!activeThreadId || files.length === 0) return; @@ -2658,7 +2870,7 @@ export default function ChatView({ threadId }: ChatViewProps) { closeShortcutLabel={closeTerminalShortcutLabel ?? undefined} onActiveTerminalChange={activateTerminal} onCloseTerminal={closeTerminal} - onHeightChange={(height) => setThreadTerminalHeight(activeThread.id, height)} + onHeightChange={setTerminalHeight} /> ); })()} diff --git a/apps/web/src/draftThreadTerminalState.test.ts b/apps/web/src/draftThreadTerminalState.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..2b84282f514e0707311b470f34522dbd9c15e415 --- /dev/null +++ b/apps/web/src/draftThreadTerminalState.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from "vitest"; + +import { + createDefaultDraftThreadTerminalState, + reduceDraftThreadTerminalState, +} from "./draftThreadTerminalState"; + +describe("draftThreadTerminalState", () => { + it("creates a closed default terminal state", () => { + expect(createDefaultDraftThreadTerminalState()).toEqual({ + terminalOpen: false, + terminalHeight: 280, + terminalIds: ["default"], + runningTerminalIds: [], + activeTerminalId: "default", + terminalGroups: [{ id: "group-default", terminalIds: ["default"] }], + activeTerminalGroupId: "group-default", + }); + }); + + it("opens and splits terminal tabs into the active group", () => { + const opened = reduceDraftThreadTerminalState(createDefaultDraftThreadTerminalState(), { + type: "set-open", + open: true, + }); + const split = reduceDraftThreadTerminalState(opened, { + type: "split", + terminalId: "terminal-2", + }); + + expect(split.terminalOpen).toBe(true); + expect(split.terminalIds).toEqual(["default", "terminal-2"]); + expect(split.activeTerminalId).toBe("terminal-2"); + expect(split.terminalGroups).toEqual([ + { + id: "group-default", + terminalIds: ["default", "terminal-2"], + }, + ]); + }); + + it("creates new terminals in a new group", () => { + const next = reduceDraftThreadTerminalState(createDefaultDraftThreadTerminalState(), { + type: "new", + terminalId: "terminal-2", + }); + + expect(next.terminalIds).toEqual(["default", "terminal-2"]); + expect(next.activeTerminalId).toBe("terminal-2"); + expect(next.activeTerminalGroupId).toBe("group-terminal-2"); + expect(next.terminalGroups).toEqual([ + { id: "group-default", terminalIds: ["default"] }, + { id: "group-terminal-2", terminalIds: ["terminal-2"] }, + ]); + }); + + it("resets to default when closing the last terminal", () => { + const closed = reduceDraftThreadTerminalState(createDefaultDraftThreadTerminalState(), { + type: "close", + terminalId: "default", + }); + + expect(closed).toEqual(createDefaultDraftThreadTerminalState()); + }); + + it("keeps a valid active terminal after closing an active split terminal", () => { + const splitOnce = reduceDraftThreadTerminalState(createDefaultDraftThreadTerminalState(), { + type: "split", + terminalId: "terminal-2", + }); + const splitTwice = reduceDraftThreadTerminalState(splitOnce, { + type: "split", + terminalId: "terminal-3", + }); + + const closed = reduceDraftThreadTerminalState(splitTwice, { + type: "close", + terminalId: "terminal-3", + }); + + expect(closed.activeTerminalId).toBe("terminal-2"); + expect(closed.terminalIds).toEqual(["default", "terminal-2"]); + expect(closed.terminalGroups).toEqual([ + { id: "group-default", terminalIds: ["default", "terminal-2"] }, + ]); + }); +}); diff --git a/apps/web/src/draftThreadTerminalState.ts b/apps/web/src/draftThreadTerminalState.ts new file mode 100644 index 0000000000000000000000000000000000000000..0e8d23e25f8fad42e59467291cf855e73ff8db7c --- /dev/null +++ b/apps/web/src/draftThreadTerminalState.ts @@ -0,0 +1,332 @@ +import { + DEFAULT_THREAD_TERMINAL_HEIGHT, + DEFAULT_THREAD_TERMINAL_ID, + MAX_THREAD_TERMINAL_COUNT, + type ThreadTerminalGroup, +} from "./types"; + +export interface DraftThreadTerminalState { + terminalOpen: boolean; + terminalHeight: number; + terminalIds: string[]; + runningTerminalIds: string[]; + activeTerminalId: string; + terminalGroups: ThreadTerminalGroup[]; + activeTerminalGroupId: string; +} + +export type DraftThreadTerminalAction = + | { type: "set-open"; open: boolean } + | { type: "set-height"; height: number } + | { type: "split"; terminalId: string } + | { type: "new"; terminalId: string } + | { type: "set-active"; terminalId: string } + | { type: "close"; terminalId: string } + | { type: "set-activity"; terminalId: string; hasRunningSubprocess: boolean }; + +function normalizeTerminalIds(terminalIds: string[]): string[] { + return [...new Set(terminalIds.map((id) => id.trim()).filter((id) => id.length > 0))]; +} + +function fallbackGroupId(terminalId: string): string { + return `group-${terminalId}`; +} + +function assignUniqueGroupId(baseId: string, usedGroupIds: Set): string { + let candidate = baseId; + let index = 2; + while (usedGroupIds.has(candidate)) { + candidate = `${baseId}-${index}`; + index += 1; + } + usedGroupIds.add(candidate); + return candidate; +} + +function findGroupIndexByTerminalId(terminalGroups: ThreadTerminalGroup[], terminalId: string): number { + return terminalGroups.findIndex((group) => group.terminalIds.includes(terminalId)); +} + +function normalizeTerminalGroups( + terminalGroups: ThreadTerminalGroup[], + terminalIds: string[], +): ThreadTerminalGroup[] { + const validTerminalIdSet = new Set(terminalIds); + const assignedTerminalIds = new Set(); + const nextGroups: ThreadTerminalGroup[] = []; + const usedGroupIds = new Set(); + + for (const group of terminalGroups) { + const groupTerminalIds = normalizeTerminalIds(group.terminalIds).filter((terminalId) => { + if (!validTerminalIdSet.has(terminalId)) return false; + if (assignedTerminalIds.has(terminalId)) return false; + return true; + }); + if (groupTerminalIds.length === 0) { + continue; + } + + for (const terminalId of groupTerminalIds) { + assignedTerminalIds.add(terminalId); + } + + nextGroups.push({ + id: assignUniqueGroupId(group.id, usedGroupIds), + terminalIds: groupTerminalIds, + }); + } + + for (const terminalId of terminalIds) { + if (assignedTerminalIds.has(terminalId)) continue; + nextGroups.push({ + id: assignUniqueGroupId(fallbackGroupId(terminalId), usedGroupIds), + terminalIds: [terminalId], + }); + } + + if (nextGroups.length === 0) { + return [ + { + id: fallbackGroupId(DEFAULT_THREAD_TERMINAL_ID), + terminalIds: [DEFAULT_THREAD_TERMINAL_ID], + }, + ]; + } + + return nextGroups; +} + +export function createDefaultDraftThreadTerminalState(): DraftThreadTerminalState { + return { + terminalOpen: false, + terminalHeight: DEFAULT_THREAD_TERMINAL_HEIGHT, + terminalIds: [DEFAULT_THREAD_TERMINAL_ID], + runningTerminalIds: [], + activeTerminalId: DEFAULT_THREAD_TERMINAL_ID, + terminalGroups: [ + { + id: fallbackGroupId(DEFAULT_THREAD_TERMINAL_ID), + terminalIds: [DEFAULT_THREAD_TERMINAL_ID], + }, + ], + activeTerminalGroupId: fallbackGroupId(DEFAULT_THREAD_TERMINAL_ID), + }; +} + +function normalizeDraftThreadTerminals(state: DraftThreadTerminalState): DraftThreadTerminalState { + const terminalIds = normalizeTerminalIds(state.terminalIds); + const nextTerminalIds = terminalIds.length > 0 ? terminalIds : [DEFAULT_THREAD_TERMINAL_ID]; + const runningTerminalIds = normalizeTerminalIds(state.runningTerminalIds).filter((terminalId) => + nextTerminalIds.includes(terminalId), + ); + const activeTerminalId = nextTerminalIds.includes(state.activeTerminalId) + ? state.activeTerminalId + : (nextTerminalIds[0] ?? DEFAULT_THREAD_TERMINAL_ID); + const terminalGroups = normalizeTerminalGroups(state.terminalGroups, nextTerminalIds); + const activeGroupIdFromState = terminalGroups.some((group) => group.id === state.activeTerminalGroupId) + ? state.activeTerminalGroupId + : null; + const activeGroupIdFromTerminal = + terminalGroups.find((group) => group.terminalIds.includes(activeTerminalId))?.id ?? null; + + return { + terminalOpen: state.terminalOpen, + terminalHeight: + Number.isFinite(state.terminalHeight) && state.terminalHeight > 0 + ? state.terminalHeight + : DEFAULT_THREAD_TERMINAL_HEIGHT, + terminalIds: nextTerminalIds, + runningTerminalIds, + activeTerminalId, + terminalGroups, + activeTerminalGroupId: + activeGroupIdFromState ?? + activeGroupIdFromTerminal ?? + terminalGroups[0]?.id ?? + fallbackGroupId(DEFAULT_THREAD_TERMINAL_ID), + }; +} + +function closeDraftTerminal( + state: DraftThreadTerminalState, + terminalId: string, +): DraftThreadTerminalState { + if (!state.terminalIds.includes(terminalId)) { + return state; + } + + const remainingTerminalIds = state.terminalIds.filter((id) => id !== terminalId); + if (remainingTerminalIds.length === 0) { + return createDefaultDraftThreadTerminalState(); + } + + const closedTerminalIndex = state.terminalIds.indexOf(terminalId); + const nextActiveTerminalId = + state.activeTerminalId === terminalId + ? (remainingTerminalIds[Math.min(closedTerminalIndex, remainingTerminalIds.length - 1)] ?? + remainingTerminalIds[0] ?? + DEFAULT_THREAD_TERMINAL_ID) + : state.activeTerminalId; + + const terminalGroups = state.terminalGroups + .map((group) => ({ + ...group, + terminalIds: group.terminalIds.filter((id) => id !== terminalId), + })) + .filter((group) => group.terminalIds.length > 0); + + const nextActiveTerminalGroupId = + terminalGroups.find((group) => group.terminalIds.includes(nextActiveTerminalId))?.id ?? + terminalGroups[0]?.id ?? + fallbackGroupId(nextActiveTerminalId); + + return normalizeDraftThreadTerminals({ + terminalOpen: state.terminalOpen, + terminalHeight: state.terminalHeight, + terminalIds: remainingTerminalIds, + runningTerminalIds: state.runningTerminalIds.filter((id) => id !== terminalId), + activeTerminalId: nextActiveTerminalId, + terminalGroups, + activeTerminalGroupId: nextActiveTerminalGroupId, + }); +} + +function copyTerminalGroups(groups: ThreadTerminalGroup[]): ThreadTerminalGroup[] { + return groups.map((group) => ({ + id: group.id, + terminalIds: [...group.terminalIds], + })); +} + +export function reduceDraftThreadTerminalState( + state: DraftThreadTerminalState, + action: DraftThreadTerminalAction, +): DraftThreadTerminalState { + const normalized = normalizeDraftThreadTerminals(state); + + if (action.type === "set-open") { + if (normalized.terminalOpen === action.open) return normalized; + return { + ...normalized, + terminalOpen: action.open, + }; + } + + if (action.type === "set-height") { + if (!Number.isFinite(action.height) || action.height <= 0 || normalized.terminalHeight === action.height) { + return normalized; + } + return { + ...normalized, + terminalHeight: action.height, + }; + } + + if (action.type === "set-active") { + if (!normalized.terminalIds.includes(action.terminalId)) { + return normalized; + } + const activeTerminalGroupId = + normalized.terminalGroups.find((group) => group.terminalIds.includes(action.terminalId))?.id ?? + normalized.activeTerminalGroupId; + return { + ...normalized, + activeTerminalId: action.terminalId, + activeTerminalGroupId, + }; + } + + if (action.type === "close") { + return closeDraftTerminal(normalized, action.terminalId); + } + + if (action.type === "set-activity") { + if (!normalized.terminalIds.includes(action.terminalId)) { + return normalized; + } + const runningTerminalIds = new Set(normalized.runningTerminalIds); + if (action.hasRunningSubprocess) { + runningTerminalIds.add(action.terminalId); + } else { + runningTerminalIds.delete(action.terminalId); + } + return { + ...normalized, + runningTerminalIds: [...runningTerminalIds], + }; + } + + if (!action.terminalId || action.terminalId.trim().length === 0) { + return normalized; + } + + const isNewTerminal = !normalized.terminalIds.includes(action.terminalId); + if (isNewTerminal && normalized.terminalIds.length >= MAX_THREAD_TERMINAL_COUNT) { + return normalized; + } + + const terminalIds = isNewTerminal + ? [...normalized.terminalIds, action.terminalId] + : normalized.terminalIds; + const terminalGroups = copyTerminalGroups(normalized.terminalGroups); + const existingGroupIndex = findGroupIndexByTerminalId(terminalGroups, action.terminalId); + if (existingGroupIndex >= 0) { + terminalGroups[existingGroupIndex]!.terminalIds = terminalGroups[existingGroupIndex]!.terminalIds.filter( + (id) => id !== action.terminalId, + ); + if (terminalGroups[existingGroupIndex]!.terminalIds.length === 0) { + terminalGroups.splice(existingGroupIndex, 1); + } + } + + if (action.type === "new") { + const usedGroupIds = new Set(terminalGroups.map((group) => group.id)); + const nextGroupId = assignUniqueGroupId(fallbackGroupId(action.terminalId), usedGroupIds); + terminalGroups.push({ id: nextGroupId, terminalIds: [action.terminalId] }); + return normalizeDraftThreadTerminals({ + ...normalized, + terminalOpen: true, + terminalIds, + activeTerminalId: action.terminalId, + terminalGroups, + activeTerminalGroupId: nextGroupId, + }); + } + + let activeGroupIndex = terminalGroups.findIndex((group) => group.id === normalized.activeTerminalGroupId); + if (activeGroupIndex < 0) { + activeGroupIndex = findGroupIndexByTerminalId(terminalGroups, normalized.activeTerminalId); + } + if (activeGroupIndex < 0) { + const usedGroupIds = new Set(terminalGroups.map((group) => group.id)); + const nextGroupId = assignUniqueGroupId( + fallbackGroupId(normalized.activeTerminalId), + usedGroupIds, + ); + terminalGroups.push({ id: nextGroupId, terminalIds: [normalized.activeTerminalId] }); + activeGroupIndex = terminalGroups.length - 1; + } + + const destinationGroup = terminalGroups[activeGroupIndex]; + if (!destinationGroup) { + return normalized; + } + + if (!destinationGroup.terminalIds.includes(action.terminalId)) { + const anchorIndex = destinationGroup.terminalIds.indexOf(normalized.activeTerminalId); + if (anchorIndex >= 0) { + destinationGroup.terminalIds.splice(anchorIndex + 1, 0, action.terminalId); + } else { + destinationGroup.terminalIds.push(action.terminalId); + } + } + + return normalizeDraftThreadTerminals({ + ...normalized, + terminalOpen: true, + terminalIds, + activeTerminalId: action.terminalId, + terminalGroups, + activeTerminalGroupId: destinationGroup.id, + }); +} diff --git a/apps/web/src/store.test.ts b/apps/web/src/store.test.ts index fe9593b19e816b7b62ed6c0b3e47683b0ca0685d..743d85b67b3a3be01b87ce7c6f212f7be3e92264 100644 --- a/apps/web/src/store.test.ts +++ b/apps/web/src/store.test.ts @@ -166,4 +166,43 @@ describe("store terminal activity action", () => { 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 1f2c5eed50655fa16291598a039475282d52678e..791254edb7e5df864265ecb3999449f8b5fbd063 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -21,6 +21,49 @@ import { type ThreadTerminalGroup, } from "./types"; +// ── Actions ────────────────────────────────────────────────────────── + +type Action = + | { type: "SYNC_SERVER_READ_MODEL"; readModel: OrchestrationReadModel } + | { + type: "HYDRATE_THREAD_TERMINALS"; + threadId: ThreadId; + terminalState: { + terminalOpen: boolean; + terminalHeight: number; + terminalIds: string[]; + runningTerminalIds: string[]; + activeTerminalId: string; + terminalGroups: ThreadTerminalGroup[]; + activeTerminalGroupId: string; + }; + } + | { 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 { @@ -436,46 +479,229 @@ function syncServerReadModelState(state: AppState, readModel: OrchestrationReadM .map((thread) => { const existing = existingThreadById.get(thread.id); - return normalizeThreadTerminals({ - id: thread.id, - codexThreadId: thread.session?.providerThreadId ?? null, - projectId: thread.projectId, - title: thread.title, - model: resolveModelSlug(thread.model), - terminalOpen: existing?.terminalOpen ?? false, - terminalHeight: existing?.terminalHeight ?? DEFAULT_THREAD_TERMINAL_HEIGHT, - terminalIds: existing?.terminalIds ?? [DEFAULT_THREAD_TERMINAL_ID], - runningTerminalIds: existing?.runningTerminalIds ?? [], - activeTerminalId: existing?.activeTerminalId ?? DEFAULT_THREAD_TERMINAL_ID, - terminalGroups: existing?.terminalGroups ?? [ - { - id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, - terminalIds: [DEFAULT_THREAD_TERMINAL_ID], - }, - ], - activeTerminalGroupId: existing?.activeTerminalGroupId ?? `group-${DEFAULT_THREAD_TERMINAL_ID}`, - 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)), +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 threads = action.readModel.threads + .filter((thread) => thread.deletedAt === null) + .map((thread) => { + const existing = existingThreadById.get(thread.id); + + return normalizeThreadTerminals({ + id: thread.id, + codexThreadId: thread.session?.providerThreadId ?? null, + projectId: thread.projectId, + title: thread.title, + model: resolveModelSlug(thread.model), + terminalOpen: existing?.terminalOpen ?? false, + terminalHeight: existing?.terminalHeight ?? DEFAULT_THREAD_TERMINAL_HEIGHT, + terminalIds: existing?.terminalIds ?? [DEFAULT_THREAD_TERMINAL_ID], + runningTerminalIds: existing?.runningTerminalIds ?? [], + activeTerminalId: existing?.activeTerminalId ?? DEFAULT_THREAD_TERMINAL_ID, + terminalGroups: existing?.terminalGroups ?? [ + { + id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, + terminalIds: [DEFAULT_THREAD_TERMINAL_ID], + }, + ], + activeTerminalGroupId: + existing?.activeTerminalGroupId ?? `group-${DEFAULT_THREAD_TERMINAL_ID}`, + 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) => + normalizeThreadTerminals({ + ...thread, + terminalOpen: action.terminalState.terminalOpen, + terminalHeight: action.terminalState.terminalHeight, + terminalIds: action.terminalState.terminalIds, + runningTerminalIds: action.terminalState.runningTerminalIds, + activeTerminalId: action.terminalState.activeTerminalId, + terminalGroups: action.terminalState.terminalGroups, + activeTerminalGroupId: action.terminalState.activeTerminalGroupId, + }), + ), + }; + + 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) => + setThreadTerminalActivity(thread, action.terminalId, 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, (t) => ({ + ...t, + terminalOpen: !t.terminalOpen, + })), + }; + + case "SET_THREAD_TERMINAL_OPEN": + return { + ...state, + threads: updateThread(state.threads, action.threadId, (t) => ({ + ...t, + terminalOpen: action.open, + })), + }; + + case "SET_THREAD_TERMINAL_HEIGHT": + return { + ...state, + threads: updateThread(state.threads, action.threadId, (t) => ({ + ...t, + terminalHeight: action.height, + })), + }; + + case "SPLIT_THREAD_TERMINAL": + return { + ...state, + threads: updateThread(state.threads, action.threadId, (thread) => { + const normalizedThread = normalizeThreadTerminals(thread); + const isNewTerminal = !normalizedThread.terminalIds.includes(action.terminalId); + if (isNewTerminal && normalizedThread.terminalIds.length >= MAX_THREAD_TERMINAL_COUNT) { + return normalizedThread; + } + const terminalIds = normalizedThread.terminalIds.includes(action.terminalId) + ? normalizedThread.terminalIds + : [...normalizedThread.terminalIds, action.terminalId]; + const terminalGroups = normalizedThread.terminalGroups.map((group) => ({ + ...group, + terminalIds: [...group.terminalIds], })); const normalizedMessage: ChatMessage = { id: message.id, -- 2.54.0