authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-12 20:27:16-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-02-12 21:24:49-08:00
logee463f6b76a9217fa1d5eb03519c039bed3db1cb
treef109f46abfd376d868d7408964dadc5e537425da
parent800eb3b547aa5e073248f1db9272efe556df6928

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

4 files changed, 182 insertions(+), 18 deletions(-)

apps/web/src/components/ChatView.tsx+16
...@@ -299,6 +299,21 @@ export default function ChatView() {...@@ -299,6 +299,21 @@ export default function ChatView() {
299 },299 },
300 [activeThreadId, dispatch],300 [activeThreadId, dispatch],
301 );301 );
302 const closeTerminal = useCallback(
303 (terminalId: string) => {
304 if (!activeThreadId || !api) return;
305 void api.terminal.write({ threadId: activeThreadId, terminalId, data: "exit\n" }).catch(() => {
306 // Ignore write errors so the UI can still close an uninitialized or already-exited terminal.
307 });
308 dispatch({
309 type: "CLOSE_THREAD_TERMINAL",
310 threadId: activeThreadId,
311 terminalId,
312 });
313 setTerminalFocusRequestId((value) => value + 1);
314 },
315 [activeThreadId, api, dispatch],
316 );
302317
303 const handleRuntimeModeChange = async (mode: "approval-required" | "full-access") => {318 const handleRuntimeModeChange = async (mode: "approval-required" | "full-access") => {
304 if (mode === state.runtimeMode) return;319 if (mode === state.runtimeMode) return;
...@@ -1489,6 +1504,7 @@ export default function ChatView() {...@@ -1489,6 +1504,7 @@ export default function ChatView() {
1489 onSplitTerminal={splitTerminal}1504 onSplitTerminal={splitTerminal}
1490 onNewTerminal={createNewTerminal}1505 onNewTerminal={createNewTerminal}
1491 onActiveTerminalChange={activateTerminal}1506 onActiveTerminalChange={activateTerminal}
1507 onCloseTerminal={closeTerminal}
1492 onHeightChange={(height) =>1508 onHeightChange={(height) =>
1493 dispatch({1509 dispatch({
1494 type: "SET_THREAD_TERMINAL_HEIGHT",1510 type: "SET_THREAD_TERMINAL_HEIGHT",
apps/web/src/components/ThreadTerminalDrawer.tsx+79-18
...@@ -421,6 +421,7 @@ interface ThreadTerminalDrawerProps {...@@ -421,6 +421,7 @@ interface ThreadTerminalDrawerProps {
421 onSplitTerminal: () => void;421 onSplitTerminal: () => void;
422 onNewTerminal: () => void;422 onNewTerminal: () => void;
423 onActiveTerminalChange: (terminalId: string) => void;423 onActiveTerminalChange: (terminalId: string) => void;
424 onCloseTerminal: (terminalId: string) => void;
424 onHeightChange: (height: number) => void;425 onHeightChange: (height: number) => void;
425}426}
426427
...@@ -437,6 +438,7 @@ export default function ThreadTerminalDrawer({...@@ -437,6 +438,7 @@ export default function ThreadTerminalDrawer({
437 onSplitTerminal,438 onSplitTerminal,
438 onNewTerminal,439 onNewTerminal,
439 onActiveTerminalChange,440 onActiveTerminalChange,
441 onCloseTerminal,
440 onHeightChange,442 onHeightChange,
441}: ThreadTerminalDrawerProps) {443}: ThreadTerminalDrawerProps) {
442 const [drawerHeight, setDrawerHeight] = useState(() => clampDrawerHeight(height));444 const [drawerHeight, setDrawerHeight] = useState(() => clampDrawerHeight(height));
...@@ -482,6 +484,13 @@ export default function ThreadTerminalDrawer({...@@ -482,6 +484,13 @@ export default function ThreadTerminalDrawer({
482 : [resolvedActiveTerminalId];484 : [resolvedActiveTerminalId];
483485
484 const showTabBar = terminalLayout === "tabs" && normalizedTerminalIds.length > 1;486 const showTabBar = terminalLayout === "tabs" && normalizedTerminalIds.length > 1;
487 const terminalLabelById = useMemo(
488 () =>
489 new Map(
490 normalizedTerminalIds.map((terminalId, index) => [terminalId, `Terminal ${index + 1}`]),
491 ),
492 [normalizedTerminalIds],
493 );
485494
486 useEffect(() => {495 useEffect(() => {
487 onHeightChangeRef.current = onHeightChange;496 onHeightChangeRef.current = onHeightChange;
...@@ -593,21 +602,37 @@ export default function ThreadTerminalDrawer({...@@ -593,21 +602,37 @@ export default function ThreadTerminalDrawer({
593 {showTabBar ? (602 {showTabBar ? (
594 <div className="min-w-0 flex-1 overflow-x-auto">603 <div className="min-w-0 flex-1 overflow-x-auto">
595 <div className="inline-flex rounded-md border border-border/80 bg-muted/40 p-0.5">604 <div className="inline-flex rounded-md border border-border/80 bg-muted/40 p-0.5">
596 {normalizedTerminalIds.map((terminalId, index) => {605 {normalizedTerminalIds.map((terminalId) => {
597 const isActive = terminalId === resolvedActiveTerminalId;606 const isActive = terminalId === resolvedActiveTerminalId;
598 return (607 return (
599 <button608 <div
600 key={terminalId}609 key={terminalId}
601 type="button"610 className={`inline-flex items-center gap-1 rounded px-2.5 py-1 text-xs transition-colors ${
602 className={`rounded px-2.5 py-1 text-xs transition-colors ${
603 isActive611 isActive
604 ? "bg-background text-foreground shadow-sm"612 ? "bg-background text-foreground shadow-sm"
605 : "text-muted-foreground hover:text-foreground"613 : "text-muted-foreground"
606 }`}614 }`}
607 onClick={() => onActiveTerminalChange(terminalId)}
608 >615 >
609 {`Terminal ${index + 1}`}616 <button
610 </button>617 type="button"
618 className="pr-1 hover:text-foreground"
619 onClick={() => onActiveTerminalChange(terminalId)}
620 >
621 {terminalLabelById.get(terminalId) ?? "Terminal"}
622 </button>
623 <button
624 type="button"
625 className="rounded px-1 text-[11px] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
626 onClick={(event) => {
627 event.stopPropagation();
628 onCloseTerminal(terminalId);
629 }}
630 aria-label={`Close ${terminalLabelById.get(terminalId) ?? "terminal"}`}
631 title={`Close ${terminalLabelById.get(terminalId) ?? "terminal"}`}
632 >
633 ×
634 </button>
635 </div>
611 );636 );
612 })}637 })}
613 </div>638 </div>
...@@ -632,6 +657,14 @@ export default function ThreadTerminalDrawer({...@@ -632,6 +657,14 @@ export default function ThreadTerminalDrawer({
632 >657 >
633 New Terminal658 New Terminal
634 </button>659 </button>
660 <div className="h-5 w-px bg-border/80" />
661 <button
662 type="button"
663 className="px-2.5 py-1 text-xs text-foreground/90 transition-colors hover:bg-accent"
664 onClick={() => onCloseTerminal(resolvedActiveTerminalId)}
665 >
666 Close Terminal
667 </button>
635 </div>668 </div>
636 </div>669 </div>
637670
...@@ -639,17 +672,45 @@ export default function ThreadTerminalDrawer({...@@ -639,17 +672,45 @@ export default function ThreadTerminalDrawer({
639 {terminalLayout === "split" && visibleTerminalIds.length === 2 ? (672 {terminalLayout === "split" && visibleTerminalIds.length === 2 ? (
640 <div className="grid h-full w-full grid-cols-2 gap-1">673 <div className="grid h-full w-full grid-cols-2 gap-1">
641 {visibleTerminalIds.map((terminalId) => (674 {visibleTerminalIds.map((terminalId) => (
642 <TerminalViewport675 <div
643 key={terminalId}676 key={terminalId}
644 api={api}677 className="flex min-h-0 flex-col rounded-[4px] border border-border/70"
645 threadId={threadId}678 >
646 terminalId={terminalId}679 <div className="flex items-center justify-between border-b border-border/70 px-2 py-1">
647 cwd={cwd}680 <button
648 focusRequestId={focusRequestId}681 type="button"
649 autoFocus={terminalId === resolvedActiveTerminalId}682 className={`text-xs ${
650 resizeEpoch={resizeEpoch}683 terminalId === resolvedActiveTerminalId
651 drawerHeight={drawerHeight}684 ? "text-foreground"
652 />685 : "text-muted-foreground hover:text-foreground"
686 }`}
687 onClick={() => onActiveTerminalChange(terminalId)}
688 >
689 {terminalLabelById.get(terminalId) ?? "Terminal"}
690 </button>
691 <button
692 type="button"
693 className="rounded px-1 text-xs text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
694 onClick={() => onCloseTerminal(terminalId)}
695 aria-label={`Close ${terminalLabelById.get(terminalId) ?? "terminal"}`}
696 title={`Close ${terminalLabelById.get(terminalId) ?? "terminal"}`}
697 >
698 ×
699 </button>
700 </div>
701 <div className="min-h-0 flex-1">
702 <TerminalViewport
703 api={api}
704 threadId={threadId}
705 terminalId={terminalId}
706 cwd={cwd}
707 focusRequestId={focusRequestId}
708 autoFocus={terminalId === resolvedActiveTerminalId}
709 resizeEpoch={resizeEpoch}
710 drawerHeight={drawerHeight}
711 />
712 </div>
713 </div>
653 ))}714 ))}
654 </div>715 </div>
655 ) : (716 ) : (
apps/web/src/store.test.ts+36
...@@ -160,6 +160,42 @@ describe("store reducer thread continuity", () => {...@@ -160,6 +160,42 @@ describe("store reducer thread continuity", () => {
160 expect(next.threads[0]?.activeTerminalId).toBe("term-2");160 expect(next.threads[0]?.activeTerminalId).toBe("term-2");
161 });161 });
162162
163 it("closes a terminal tab and falls back to single layout when one remains", () => {
164 const state = makeState(
165 makeThread({
166 terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2"],
167 activeTerminalId: "term-2",
168 terminalLayout: "tabs",
169 }),
170 );
171 const next = reducer(state, {
172 type: "CLOSE_THREAD_TERMINAL",
173 threadId: "thread-local-1",
174 terminalId: "term-2",
175 });
176
177 expect(next.threads[0]?.terminalIds).toEqual([DEFAULT_THREAD_TERMINAL_ID]);
178 expect(next.threads[0]?.activeTerminalId).toBe(DEFAULT_THREAD_TERMINAL_ID);
179 expect(next.threads[0]?.terminalLayout).toBe("single");
180 });
181
182 it("closes the final terminal and hides the drawer", () => {
183 const state = makeState(
184 makeThread({
185 terminalOpen: true,
186 }),
187 );
188 const next = reducer(state, {
189 type: "CLOSE_THREAD_TERMINAL",
190 threadId: "thread-local-1",
191 terminalId: DEFAULT_THREAD_TERMINAL_ID,
192 });
193
194 expect(next.threads[0]?.terminalOpen).toBe(false);
195 expect(next.threads[0]?.terminalIds).toEqual([DEFAULT_THREAD_TERMINAL_ID]);
196 expect(next.threads[0]?.terminalLayout).toBe("single");
197 });
198
163 it("backfills codexThreadId from routed provider events", () => {199 it("backfills codexThreadId from routed provider events", () => {
164 const state = makeState(makeThread({ codexThreadId: null }));200 const state = makeState(makeThread({ codexThreadId: null }));
165 const next = reducer(state, {201 const next = reducer(state, {
apps/web/src/store.ts+51
...@@ -35,6 +35,7 @@ type Action =...@@ -35,6 +35,7 @@ type Action =
35 | { type: "SPLIT_THREAD_TERMINAL"; threadId: string; terminalId: string }35 | { type: "SPLIT_THREAD_TERMINAL"; threadId: string; terminalId: string }
36 | { type: "NEW_THREAD_TERMINAL"; threadId: string; terminalId: string }36 | { type: "NEW_THREAD_TERMINAL"; threadId: string; terminalId: string }
37 | { type: "SET_THREAD_ACTIVE_TERMINAL"; threadId: string; terminalId: string }37 | { type: "SET_THREAD_ACTIVE_TERMINAL"; threadId: string; terminalId: string }
38 | { type: "CLOSE_THREAD_TERMINAL"; threadId: string; terminalId: string }
38 | { type: "TOGGLE_DIFF" }39 | { type: "TOGGLE_DIFF" }
39 | {40 | {
40 type: "APPLY_EVENT";41 type: "APPLY_EVENT";
...@@ -189,6 +190,48 @@ function normalizeThreadTerminals(thread: Thread): Thread {...@@ -189,6 +190,48 @@ function normalizeThreadTerminals(thread: Thread): Thread {
189 };190 };
190}191}
191192
193function closeThreadTerminal(thread: Thread, terminalId: string): Thread {
194 if (!thread.terminalIds.includes(terminalId)) {
195 return thread;
196 }
197
198 const remainingTerminalIds = thread.terminalIds.filter((id) => id !== terminalId);
199 if (remainingTerminalIds.length === 0) {
200 return normalizeThreadTerminals({
201 ...thread,
202 terminalOpen: false,
203 terminalIds: [DEFAULT_THREAD_TERMINAL_ID],
204 activeTerminalId: DEFAULT_THREAD_TERMINAL_ID,
205 terminalLayout: "single",
206 splitTerminalIds: [],
207 });
208 }
209
210 const nextActiveTerminalId =
211 thread.activeTerminalId === terminalId
212 ? (remainingTerminalIds[0] ?? DEFAULT_THREAD_TERMINAL_ID)
213 : thread.activeTerminalId;
214 let nextLayout = thread.terminalLayout;
215 let nextSplitTerminalIds = thread.splitTerminalIds.filter((id) => id !== terminalId);
216
217 if (nextLayout === "split" && nextSplitTerminalIds.length < 2) {
218 nextLayout = remainingTerminalIds.length > 1 ? "tabs" : "single";
219 nextSplitTerminalIds = [];
220 }
221
222 if (nextLayout === "tabs" && remainingTerminalIds.length < 2) {
223 nextLayout = "single";
224 }
225
226 return normalizeThreadTerminals({
227 ...thread,
228 terminalIds: remainingTerminalIds,
229 activeTerminalId: nextActiveTerminalId,
230 terminalLayout: nextLayout,
231 splitTerminalIds: nextSplitTerminalIds,
232 });
233}
234
192function findThreadBySessionId(threads: Thread[], sessionId: string): Thread | undefined {235function findThreadBySessionId(threads: Thread[], sessionId: string): Thread | undefined {
193 return threads.find((t) => t.session?.sessionId === sessionId);236 return threads.find((t) => t.session?.sessionId === sessionId);
194}237}
...@@ -454,6 +497,14 @@ export function reducer(state: AppState, action: Action): AppState {...@@ -454,6 +497,14 @@ export function reducer(state: AppState, action: Action): AppState {
454 }),497 }),
455 };498 };
456499
500 case "CLOSE_THREAD_TERMINAL":
501 return {
502 ...state,
503 threads: updateThread(state.threads, action.threadId, (thread) =>
504 closeThreadTerminal(thread, action.terminalId),
505 ),
506 };
507
457 case "TOGGLE_DIFF":508 case "TOGGLE_DIFF":
458 return { ...state, diffOpen: !state.diffOpen };509 return { ...state, diffOpen: !state.diffOpen };
459510