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,