From ee463f6b76a9217fa1d5eb03519c039bed3db1cb Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Feb 2026 20:27:16 -0800 Subject: [PATCH] Add close-terminal actions and state handling in thread drawer - Add terminal close controls in tab and split terminal views - Send `exit` before closing and update active terminal/focus in chat state - Add reducer support and tests for closing tabs, split fallback, and final-terminal drawer hide --- apps/web/src/components/ChatView.tsx | 16 +++ .../src/components/ThreadTerminalDrawer.tsx | 97 +++++++++++++++---- apps/web/src/store.test.ts | 36 +++++++ apps/web/src/store.ts | 51 ++++++++++ 4 files changed, 182 insertions(+), 18 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index c57c6d227d033afba211cace3b9c25828bfe63a8..4fa52c843d321c34b86fc2951d1e7ac4f3bc22b4 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -299,6 +299,21 @@ export default function ChatView() { }, [activeThreadId, dispatch], ); + const closeTerminal = useCallback( + (terminalId: string) => { + if (!activeThreadId || !api) return; + void api.terminal.write({ threadId: activeThreadId, terminalId, data: "exit\n" }).catch(() => { + // Ignore write errors so the UI can still close an uninitialized or already-exited terminal. + }); + dispatch({ + type: "CLOSE_THREAD_TERMINAL", + threadId: activeThreadId, + terminalId, + }); + setTerminalFocusRequestId((value) => value + 1); + }, + [activeThreadId, api, dispatch], + ); const handleRuntimeModeChange = async (mode: "approval-required" | "full-access") => { if (mode === state.runtimeMode) return; @@ -1489,6 +1504,7 @@ export default function ChatView() { onSplitTerminal={splitTerminal} onNewTerminal={createNewTerminal} onActiveTerminalChange={activateTerminal} + onCloseTerminal={closeTerminal} onHeightChange={(height) => dispatch({ type: "SET_THREAD_TERMINAL_HEIGHT", diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index fd9dc07786025b86bdd89785eb0658e684c2f25b..783e5886481d879e647f1645b10ca9d80e381751 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -421,6 +421,7 @@ interface ThreadTerminalDrawerProps { onSplitTerminal: () => void; onNewTerminal: () => void; onActiveTerminalChange: (terminalId: string) => void; + onCloseTerminal: (terminalId: string) => void; onHeightChange: (height: number) => void; } @@ -437,6 +438,7 @@ export default function ThreadTerminalDrawer({ onSplitTerminal, onNewTerminal, onActiveTerminalChange, + onCloseTerminal, onHeightChange, }: ThreadTerminalDrawerProps) { const [drawerHeight, setDrawerHeight] = useState(() => clampDrawerHeight(height)); @@ -482,6 +484,13 @@ export default function ThreadTerminalDrawer({ : [resolvedActiveTerminalId]; const showTabBar = terminalLayout === "tabs" && normalizedTerminalIds.length > 1; + const terminalLabelById = useMemo( + () => + new Map( + normalizedTerminalIds.map((terminalId, index) => [terminalId, `Terminal ${index + 1}`]), + ), + [normalizedTerminalIds], + ); useEffect(() => { onHeightChangeRef.current = onHeightChange; @@ -593,21 +602,37 @@ export default function ThreadTerminalDrawer({ {showTabBar ? (
- {normalizedTerminalIds.map((terminalId, index) => { + {normalizedTerminalIds.map((terminalId) => { const isActive = terminalId === resolvedActiveTerminalId; return ( - + + +
); })}
@@ -632,6 +657,14 @@ export default function ThreadTerminalDrawer({ > New Terminal +
+
@@ -639,17 +672,45 @@ export default function ThreadTerminalDrawer({ {terminalLayout === "split" && visibleTerminalIds.length === 2 ? (
{visibleTerminalIds.map((terminalId) => ( - + className="flex min-h-0 flex-col rounded-[4px] border border-border/70" + > +
+ + +
+
+ +
+
))} ) : ( diff --git a/apps/web/src/store.test.ts b/apps/web/src/store.test.ts index a06efcd063f0e6c6d91585aa25608d261b5b24b3..8e5e6d011c3a3b83e5ad9751b5920ba0277924a5 100644 --- a/apps/web/src/store.test.ts +++ b/apps/web/src/store.test.ts @@ -160,6 +160,42 @@ describe("store reducer thread continuity", () => { expect(next.threads[0]?.activeTerminalId).toBe("term-2"); }); + it("closes a terminal tab and falls back to single layout when one remains", () => { + const state = makeState( + makeThread({ + terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2"], + activeTerminalId: "term-2", + terminalLayout: "tabs", + }), + ); + const next = reducer(state, { + type: "CLOSE_THREAD_TERMINAL", + threadId: "thread-local-1", + terminalId: "term-2", + }); + + expect(next.threads[0]?.terminalIds).toEqual([DEFAULT_THREAD_TERMINAL_ID]); + expect(next.threads[0]?.activeTerminalId).toBe(DEFAULT_THREAD_TERMINAL_ID); + expect(next.threads[0]?.terminalLayout).toBe("single"); + }); + + it("closes the final terminal and hides the drawer", () => { + const state = makeState( + makeThread({ + terminalOpen: true, + }), + ); + const next = reducer(state, { + type: "CLOSE_THREAD_TERMINAL", + threadId: "thread-local-1", + terminalId: DEFAULT_THREAD_TERMINAL_ID, + }); + + expect(next.threads[0]?.terminalOpen).toBe(false); + expect(next.threads[0]?.terminalIds).toEqual([DEFAULT_THREAD_TERMINAL_ID]); + expect(next.threads[0]?.terminalLayout).toBe("single"); + }); + it("backfills codexThreadId from routed provider events", () => { const state = makeState(makeThread({ codexThreadId: null })); const next = reducer(state, { diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index 039cf3636987ae321bdd729067df1606da12db53..bd2fe31a9b94c9547e23383da9154463d0aae698 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -35,6 +35,7 @@ type Action = | { type: "SPLIT_THREAD_TERMINAL"; threadId: string; terminalId: string } | { type: "NEW_THREAD_TERMINAL"; threadId: string; terminalId: string } | { type: "SET_THREAD_ACTIVE_TERMINAL"; threadId: string; terminalId: string } + | { type: "CLOSE_THREAD_TERMINAL"; threadId: string; terminalId: string } | { type: "TOGGLE_DIFF" } | { type: "APPLY_EVENT"; @@ -189,6 +190,48 @@ function normalizeThreadTerminals(thread: Thread): Thread { }; } +function closeThreadTerminal(thread: Thread, terminalId: string): Thread { + if (!thread.terminalIds.includes(terminalId)) { + return thread; + } + + const remainingTerminalIds = thread.terminalIds.filter((id) => id !== terminalId); + if (remainingTerminalIds.length === 0) { + return normalizeThreadTerminals({ + ...thread, + terminalOpen: false, + terminalIds: [DEFAULT_THREAD_TERMINAL_ID], + activeTerminalId: DEFAULT_THREAD_TERMINAL_ID, + terminalLayout: "single", + splitTerminalIds: [], + }); + } + + const nextActiveTerminalId = + thread.activeTerminalId === terminalId + ? (remainingTerminalIds[0] ?? DEFAULT_THREAD_TERMINAL_ID) + : thread.activeTerminalId; + let nextLayout = thread.terminalLayout; + let nextSplitTerminalIds = thread.splitTerminalIds.filter((id) => id !== terminalId); + + if (nextLayout === "split" && nextSplitTerminalIds.length < 2) { + nextLayout = remainingTerminalIds.length > 1 ? "tabs" : "single"; + nextSplitTerminalIds = []; + } + + if (nextLayout === "tabs" && remainingTerminalIds.length < 2) { + nextLayout = "single"; + } + + return normalizeThreadTerminals({ + ...thread, + terminalIds: remainingTerminalIds, + activeTerminalId: nextActiveTerminalId, + terminalLayout: nextLayout, + splitTerminalIds: nextSplitTerminalIds, + }); +} + function findThreadBySessionId(threads: Thread[], sessionId: string): Thread | undefined { return threads.find((t) => t.session?.sessionId === sessionId); } @@ -454,6 +497,14 @@ export function reducer(state: AppState, action: Action): AppState { }), }; + case "CLOSE_THREAD_TERMINAL": + return { + ...state, + threads: updateThread(state.threads, action.threadId, (thread) => + closeThreadTerminal(thread, action.terminalId), + ), + }; + case "TOGGLE_DIFF": return { ...state, diffOpen: !state.diffOpen }; -- 2.54.0