| 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,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 | ); | ||
| 302 | 317 | ||
| 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 | } |
| 426 | 427 | ||
| ... | @@ -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]; |
| 483 | 485 | ||
| 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 | ); | ||
| 485 | 494 | ||
| 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 | <button | 608 | <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 | isActive | 611 | 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 Terminal | 658 | 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> |
| 637 | 670 | ||
| ... | @@ -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 | <TerminalViewport | 675 | <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 | }); |
| 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 | 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 | } |
| 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 | function findThreadBySessionId(threads: Thread[], sessionId: string): Thread | undefined { | 235 | function 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 | }; |
| 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 | case "TOGGLE_DIFF": | 508 | case "TOGGLE_DIFF": |
| 458 | return { ...state, diffOpen: !state.diffOpen }; | 509 | return { ...state, diffOpen: !state.diffOpen }; |
| 459 | 510 |