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() {
299299 },
300300 [activeThreadId, dispatch],
301301 );
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
303318 const handleRuntimeModeChange = async (mode: "approval-required" | "full-access") => {
304319 if (mode === state.runtimeMode) return;
......@@ -1489,6 +1504,7 @@ export default function ChatView() {
14891504 onSplitTerminal={splitTerminal}
14901505 onNewTerminal={createNewTerminal}
14911506 onActiveTerminalChange={activateTerminal}
1507 onCloseTerminal={closeTerminal}
14921508 onHeightChange={(height) =>
14931509 dispatch({
14941510 type: "SET_THREAD_TERMINAL_HEIGHT",
apps/web/src/components/ThreadTerminalDrawer.tsx+79-18
......@@ -421,6 +421,7 @@ interface ThreadTerminalDrawerProps {
421421 onSplitTerminal: () => void;
422422 onNewTerminal: () => void;
423423 onActiveTerminalChange: (terminalId: string) => void;
424 onCloseTerminal: (terminalId: string) => void;
424425 onHeightChange: (height: number) => void;
425426}
426427
......@@ -437,6 +438,7 @@ export default function ThreadTerminalDrawer({
437438 onSplitTerminal,
438439 onNewTerminal,
439440 onActiveTerminalChange,
441 onCloseTerminal,
440442 onHeightChange,
441443}: ThreadTerminalDrawerProps) {
442444 const [drawerHeight, setDrawerHeight] = useState(() => clampDrawerHeight(height));
......@@ -482,6 +484,13 @@ export default function ThreadTerminalDrawer({
482484 : [resolvedActiveTerminalId];
483485
484486 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
486495 useEffect(() => {
487496 onHeightChangeRef.current = onHeightChange;
......@@ -593,21 +602,37 @@ export default function ThreadTerminalDrawer({
593602 {showTabBar ? (
594603 <div className="min-w-0 flex-1 overflow-x-auto">
595604 <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) => {
597606 const isActive = terminalId === resolvedActiveTerminalId;
598607 return (
599 <button
608 <div
600609 key={terminalId}
601 type="button"
602 className={`rounded px-2.5 py-1 text-xs transition-colors ${
610 className={`inline-flex items-center gap-1 rounded px-2.5 py-1 text-xs transition-colors ${
603611 isActive
604612 ? "bg-background text-foreground shadow-sm"
605 : "text-muted-foreground hover:text-foreground"
613 : "text-muted-foreground"
606614 }`}
607 onClick={() => onActiveTerminalChange(terminalId)}
608615 >
609 {`Terminal ${index + 1}`}
610 </button>
616 <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>
611636 );
612637 })}
613638 </div>
......@@ -632,6 +657,14 @@ export default function ThreadTerminalDrawer({
632657 >
633658 New Terminal
634659 </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>
635668 </div>
636669 </div>
637670
......@@ -639,17 +672,45 @@ export default function ThreadTerminalDrawer({
639672 {terminalLayout === "split" && visibleTerminalIds.length === 2 ? (
640673 <div className="grid h-full w-full grid-cols-2 gap-1">
641674 {visibleTerminalIds.map((terminalId) => (
642 <TerminalViewport
675 <div
643676 key={terminalId}
644 api={api}
645 threadId={threadId}
646 terminalId={terminalId}
647 cwd={cwd}
648 focusRequestId={focusRequestId}
649 autoFocus={terminalId === resolvedActiveTerminalId}
650 resizeEpoch={resizeEpoch}
651 drawerHeight={drawerHeight}
652 />
677 className="flex min-h-0 flex-col rounded-[4px] border border-border/70"
678 >
679 <div className="flex items-center justify-between border-b border-border/70 px-2 py-1">
680 <button
681 type="button"
682 className={`text-xs ${
683 terminalId === resolvedActiveTerminalId
684 ? "text-foreground"
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>
653714 ))}
654715 </div>
655716 ) : (
apps/web/src/store.test.ts+36
......@@ -160,6 +160,42 @@ describe("store reducer thread continuity", () => {
160160 expect(next.threads[0]?.activeTerminalId).toBe("term-2");
161161 });
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
163199 it("backfills codexThreadId from routed provider events", () => {
164200 const state = makeState(makeThread({ codexThreadId: null }));
165201 const next = reducer(state, {
apps/web/src/store.ts+51
......@@ -35,6 +35,7 @@ type Action =
3535 | { type: "SPLIT_THREAD_TERMINAL"; threadId: string; terminalId: string }
3636 | { type: "NEW_THREAD_TERMINAL"; threadId: string; terminalId: string }
3737 | { type: "SET_THREAD_ACTIVE_TERMINAL"; threadId: string; terminalId: string }
38 | { type: "CLOSE_THREAD_TERMINAL"; threadId: string; terminalId: string }
3839 | { type: "TOGGLE_DIFF" }
3940 | {
4041 type: "APPLY_EVENT";
......@@ -189,6 +190,48 @@ function normalizeThreadTerminals(thread: Thread): Thread {
189190 };
190191}
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
192235function findThreadBySessionId(threads: Thread[], sessionId: string): Thread | undefined {
193236 return threads.find((t) => t.session?.sessionId === sessionId);
194237}
......@@ -454,6 +497,14 @@ export function reducer(state: AppState, action: Action): AppState {
454497 }),
455498 };
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
457508 case "TOGGLE_DIFF":
458509 return { ...state, diffOpen: !state.diffOpen };
459510