| author | |
| committer | |
| log | ee463f6b76a9217fa1d5eb03519c039bed3db1cb |
| tree | f109f46abfd376d868d7408964dadc5e537425da |
| parent | 800eb3b547aa5e073248f1db9272efe556df6928 |
- 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 hide4 files changed, 182 insertions(+), 18 deletions(-)
apps/web/src/components/ChatView.tsx+16| ... | ... | @@ -299,6 +299,21 @@ export default function ChatView() { |
| 299 | 299 | }, |
| 300 | 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 | ); | |
| 302 | 317 | |
| 303 | 318 | const handleRuntimeModeChange = async (mode: "approval-required" | "full-access") => { |
| 304 | 319 | if (mode === state.runtimeMode) return; |
| ... | ... | @@ -1489,6 +1504,7 @@ export default function ChatView() { |
| 1489 | 1504 | onSplitTerminal={splitTerminal} |
| 1490 | 1505 | onNewTerminal={createNewTerminal} |
| 1491 | 1506 | onActiveTerminalChange={activateTerminal} |
| 1507 | onCloseTerminal={closeTerminal} | |
| 1492 | 1508 | onHeightChange={(height) => |
| 1493 | 1509 | dispatch({ |
| 1494 | 1510 | type: "SET_THREAD_TERMINAL_HEIGHT", |
apps/web/src/components/ThreadTerminalDrawer.tsx+79-18| ... | ... | @@ -421,6 +421,7 @@ interface ThreadTerminalDrawerProps { |
| 421 | 421 | onSplitTerminal: () => void; |
| 422 | 422 | onNewTerminal: () => void; |
| 423 | 423 | onActiveTerminalChange: (terminalId: string) => void; |
| 424 | onCloseTerminal: (terminalId: string) => void; | |
| 424 | 425 | onHeightChange: (height: number) => void; |
| 425 | 426 | } |
| 426 | 427 | |
| ... | ... | @@ -437,6 +438,7 @@ export default function ThreadTerminalDrawer({ |
| 437 | 438 | onSplitTerminal, |
| 438 | 439 | onNewTerminal, |
| 439 | 440 | onActiveTerminalChange, |
| 441 | onCloseTerminal, | |
| 440 | 442 | onHeightChange, |
| 441 | 443 | }: ThreadTerminalDrawerProps) { |
| 442 | 444 | const [drawerHeight, setDrawerHeight] = useState(() => clampDrawerHeight(height)); |
| ... | ... | @@ -482,6 +484,13 @@ export default function ThreadTerminalDrawer({ |
| 482 | 484 | : [resolvedActiveTerminalId]; |
| 483 | 485 | |
| 484 | 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 | ); | |
| 485 | 494 | |
| 486 | 495 | useEffect(() => { |
| 487 | 496 | onHeightChangeRef.current = onHeightChange; |
| ... | ... | @@ -593,21 +602,37 @@ export default function ThreadTerminalDrawer({ |
| 593 | 602 | {showTabBar ? ( |
| 594 | 603 | <div className="min-w-0 flex-1 overflow-x-auto"> |
| 595 | 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 | 606 | const isActive = terminalId === resolvedActiveTerminalId; |
| 598 | 607 | return ( |
| 599 | <button | |
| 608 | <div | |
| 600 | 609 | 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 ${ | |
| 603 | 611 | isActive |
| 604 | 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}`} | |
| 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> | |
| 611 | 636 | ); |
| 612 | 637 | })} |
| 613 | 638 | </div> |
| ... | ... | @@ -632,6 +657,14 @@ export default function ThreadTerminalDrawer({ |
| 632 | 657 | > |
| 633 | 658 | New Terminal |
| 634 | 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 | 668 | </div> |
| 636 | 669 | </div> |
| 637 | 670 | |
| ... | ... | @@ -639,17 +672,45 @@ export default function ThreadTerminalDrawer({ |
| 639 | 672 | {terminalLayout === "split" && visibleTerminalIds.length === 2 ? ( |
| 640 | 673 | <div className="grid h-full w-full grid-cols-2 gap-1"> |
| 641 | 674 | {visibleTerminalIds.map((terminalId) => ( |
| 642 | <TerminalViewport | |
| 675 | <div | |
| 643 | 676 | 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> | |
| 653 | 714 | ))} |
| 654 | 715 | </div> |
| 655 | 716 | ) : ( |
apps/web/src/store.test.ts+36| ... | ... | @@ -160,6 +160,42 @@ describe("store reducer thread continuity", () => { |
| 160 | 160 | expect(next.threads[0]?.activeTerminalId).toBe("term-2"); |
| 161 | 161 | }); |
| 162 | 162 | |
| 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 | 199 | it("backfills codexThreadId from routed provider events", () => { |
| 164 | 200 | const state = makeState(makeThread({ codexThreadId: null })); |
| 165 | 201 | const next = reducer(state, { |
apps/web/src/store.ts+51| ... | ... | @@ -35,6 +35,7 @@ type Action = |
| 35 | 35 | | { type: "SPLIT_THREAD_TERMINAL"; threadId: string; terminalId: string } |
| 36 | 36 | | { type: "NEW_THREAD_TERMINAL"; threadId: string; terminalId: string } |
| 37 | 37 | | { type: "SET_THREAD_ACTIVE_TERMINAL"; threadId: string; terminalId: string } |
| 38 | | { type: "CLOSE_THREAD_TERMINAL"; threadId: string; terminalId: string } | |
| 38 | 39 | | { type: "TOGGLE_DIFF" } |
| 39 | 40 | | { |
| 40 | 41 | type: "APPLY_EVENT"; |
| ... | ... | @@ -189,6 +190,48 @@ function normalizeThreadTerminals(thread: Thread): Thread { |
| 189 | 190 | }; |
| 190 | 191 | } |
| 191 | 192 | |
| 193 | function 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 | ||
| 192 | 235 | function findThreadBySessionId(threads: Thread[], sessionId: string): Thread | undefined { |
| 193 | 236 | return threads.find((t) => t.session?.sessionId === sessionId); |
| 194 | 237 | } |
| ... | ... | @@ -454,6 +497,14 @@ export function reducer(state: AppState, action: Action): AppState { |
| 454 | 497 | }), |
| 455 | 498 | }; |
| 456 | 499 | |
| 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 | 508 | case "TOGGLE_DIFF": |
| 458 | 509 | return { ...state, diffOpen: !state.diffOpen }; |
| 459 | 510 |