| author | |
| committer | |
| log | 07210d0c912398f405a318cda7ea627126616c82 |
| tree | de6908183e32a1aee43d3f70fc0e84c9b4191e67 |
| parent | ee463f6b76a9217fa1d5eb03519c039bed3db1cb |
- Replace `terminalLayout`/`splitTerminalIds` with `terminalGroups` and `activeTerminalGroupId`
- Update terminal drawer UI to render grouped splits with a terminal sidebar
- Bump persisted web state to v7 with legacy v6 migration coverage in tests9 files changed, 783 insertions(+), 276 deletions(-)
apps/web/src/App.tsx+7-2| ... | @@ -83,8 +83,13 @@ function AutoProjectBootstrap() { | ... | @@ -83,8 +83,13 @@ function AutoProjectBootstrap() { |
| 83 | terminalHeight: DEFAULT_THREAD_TERMINAL_HEIGHT, | 83 | terminalHeight: DEFAULT_THREAD_TERMINAL_HEIGHT, |
| 84 | terminalIds: [DEFAULT_THREAD_TERMINAL_ID], | 84 | terminalIds: [DEFAULT_THREAD_TERMINAL_ID], |
| 85 | activeTerminalId: DEFAULT_THREAD_TERMINAL_ID, | 85 | activeTerminalId: DEFAULT_THREAD_TERMINAL_ID, |
| 86 | terminalLayout: "single", | 86 | terminalGroups: [ |
| 87 | splitTerminalIds: [], | 87 | { |
| 88 | id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, | ||
| 89 | terminalIds: [DEFAULT_THREAD_TERMINAL_ID], | ||
| 90 | }, | ||
| 91 | ], | ||
| 92 | activeTerminalGroupId: `group-${DEFAULT_THREAD_TERMINAL_ID}`, | ||
| 88 | session: null, | 93 | session: null, |
| 89 | messages: [], | 94 | messages: [], |
| 90 | events: [], | 95 | events: [], |
apps/web/src/components/ChatView.tsx+2-2| ... | @@ -1498,8 +1498,8 @@ export default function ChatView() { | ... | @@ -1498,8 +1498,8 @@ export default function ChatView() { |
| 1498 | height={activeThread.terminalHeight} | 1498 | height={activeThread.terminalHeight} |
| 1499 | terminalIds={activeThread.terminalIds} | 1499 | terminalIds={activeThread.terminalIds} |
| 1500 | activeTerminalId={activeThread.activeTerminalId} | 1500 | activeTerminalId={activeThread.activeTerminalId} |
| 1501 | terminalLayout={activeThread.terminalLayout} | 1501 | terminalGroups={activeThread.terminalGroups} |
| 1502 | splitTerminalIds={activeThread.splitTerminalIds} | 1502 | activeTerminalGroupId={activeThread.activeTerminalGroupId} |
| 1503 | focusRequestId={terminalFocusRequestId} | 1503 | focusRequestId={terminalFocusRequestId} |
| 1504 | onSplitTerminal={splitTerminal} | 1504 | onSplitTerminal={splitTerminal} |
| 1505 | onNewTerminal={createNewTerminal} | 1505 | onNewTerminal={createNewTerminal} |
apps/web/src/components/Sidebar.tsx+7-2| ... | @@ -119,8 +119,13 @@ export default function Sidebar() { | ... | @@ -119,8 +119,13 @@ export default function Sidebar() { |
| 119 | terminalHeight: DEFAULT_THREAD_TERMINAL_HEIGHT, | 119 | terminalHeight: DEFAULT_THREAD_TERMINAL_HEIGHT, |
| 120 | terminalIds: [DEFAULT_THREAD_TERMINAL_ID], | 120 | terminalIds: [DEFAULT_THREAD_TERMINAL_ID], |
| 121 | activeTerminalId: DEFAULT_THREAD_TERMINAL_ID, | 121 | activeTerminalId: DEFAULT_THREAD_TERMINAL_ID, |
| 122 | terminalLayout: "single", | 122 | terminalGroups: [ |
| 123 | splitTerminalIds: [], | 123 | { |
| 124 | id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, | ||
| 125 | terminalIds: [DEFAULT_THREAD_TERMINAL_ID], | ||
| 126 | }, | ||
| 127 | ], | ||
| 128 | activeTerminalGroupId: `group-${DEFAULT_THREAD_TERMINAL_ID}`, | ||
| 124 | session: null, | 129 | session: null, |
| 125 | messages: [], | 130 | messages: [], |
| 126 | events: [], | 131 | events: [], |
apps/web/src/components/ThreadTerminalDrawer.tsx+270-138| ... | @@ -1,4 +1,5 @@ | ... | @@ -1,4 +1,5 @@ |
| 1 | import { FitAddon } from "@xterm/addon-fit"; | 1 | import { FitAddon } from "@xterm/addon-fit"; |
| 2 | import { Plus, SquareSplitHorizontal, TerminalSquare, Trash2 } from "lucide-react"; | ||
| 2 | import { type NativeApi } from "@t3tools/contracts"; | 3 | import { type NativeApi } from "@t3tools/contracts"; |
| 3 | import { Terminal, type ITheme } from "@xterm/xterm"; | 4 | import { Terminal, type ITheme } from "@xterm/xterm"; |
| 4 | import { | 5 | import { |
| ... | @@ -19,7 +20,7 @@ import { isTerminalClearShortcut } from "../terminal-shortcuts"; | ... | @@ -19,7 +20,7 @@ import { isTerminalClearShortcut } from "../terminal-shortcuts"; |
| 19 | import { | 20 | import { |
| 20 | DEFAULT_THREAD_TERMINAL_HEIGHT, | 21 | DEFAULT_THREAD_TERMINAL_HEIGHT, |
| 21 | DEFAULT_THREAD_TERMINAL_ID, | 22 | DEFAULT_THREAD_TERMINAL_ID, |
| 22 | type ThreadTerminalLayout, | 23 | type ThreadTerminalGroup, |
| 23 | } from "../types"; | 24 | } from "../types"; |
| 24 | 25 | ||
| 25 | const MIN_DRAWER_HEIGHT = 180; | 26 | const MIN_DRAWER_HEIGHT = 180; |
| ... | @@ -415,8 +416,8 @@ interface ThreadTerminalDrawerProps { | ... | @@ -415,8 +416,8 @@ interface ThreadTerminalDrawerProps { |
| 415 | height: number; | 416 | height: number; |
| 416 | terminalIds: string[]; | 417 | terminalIds: string[]; |
| 417 | activeTerminalId: string; | 418 | activeTerminalId: string; |
| 418 | terminalLayout: ThreadTerminalLayout; | 419 | terminalGroups: ThreadTerminalGroup[]; |
| 419 | splitTerminalIds: string[]; | 420 | activeTerminalGroupId: string; |
| 420 | focusRequestId: number; | 421 | focusRequestId: number; |
| 421 | onSplitTerminal: () => void; | 422 | onSplitTerminal: () => void; |
| 422 | onNewTerminal: () => void; | 423 | onNewTerminal: () => void; |
| ... | @@ -432,8 +433,8 @@ export default function ThreadTerminalDrawer({ | ... | @@ -432,8 +433,8 @@ export default function ThreadTerminalDrawer({ |
| 432 | height, | 433 | height, |
| 433 | terminalIds, | 434 | terminalIds, |
| 434 | activeTerminalId, | 435 | activeTerminalId, |
| 435 | terminalLayout, | 436 | terminalGroups, |
| 436 | splitTerminalIds, | 437 | activeTerminalGroupId, |
| 437 | focusRequestId, | 438 | focusRequestId, |
| 438 | onSplitTerminal, | 439 | onSplitTerminal, |
| 439 | onNewTerminal, | 440 | onNewTerminal, |
| ... | @@ -462,28 +463,88 @@ export default function ThreadTerminalDrawer({ | ... | @@ -462,28 +463,88 @@ export default function ThreadTerminalDrawer({ |
| 462 | ? activeTerminalId | 463 | ? activeTerminalId |
| 463 | : (normalizedTerminalIds[0] ?? DEFAULT_THREAD_TERMINAL_ID); | 464 | : (normalizedTerminalIds[0] ?? DEFAULT_THREAD_TERMINAL_ID); |
| 464 | 465 | ||
| 465 | const resolvedSplitTerminalIds = useMemo(() => { | 466 | const resolvedTerminalGroups = useMemo(() => { |
| 466 | if (terminalLayout !== "split") return []; | 467 | const validTerminalIdSet = new Set(normalizedTerminalIds); |
| 467 | const validSplitIds = [...new Set(splitTerminalIds)].filter((id) => | 468 | const assignedTerminalIds = new Set<string>(); |
| 468 | normalizedTerminalIds.includes(id), | 469 | const usedGroupIds = new Set<string>(); |
| 469 | ); | 470 | const nextGroups: ThreadTerminalGroup[] = []; |
| 470 | if (validSplitIds.length >= 2) { | 471 | |
| 471 | return validSplitIds.slice(0, 2); | 472 | const assignUniqueGroupId = (groupId: string): string => { |
| 473 | if (!usedGroupIds.has(groupId)) { | ||
| 474 | usedGroupIds.add(groupId); | ||
| 475 | return groupId; | ||
| 476 | } | ||
| 477 | let suffix = 2; | ||
| 478 | while (usedGroupIds.has(`${groupId}-${suffix}`)) { | ||
| 479 | suffix += 1; | ||
| 480 | } | ||
| 481 | const uniqueGroupId = `${groupId}-${suffix}`; | ||
| 482 | usedGroupIds.add(uniqueGroupId); | ||
| 483 | return uniqueGroupId; | ||
| 484 | }; | ||
| 485 | |||
| 486 | for (const terminalGroup of terminalGroups) { | ||
| 487 | const nextTerminalIds = [ | ||
| 488 | ...new Set(terminalGroup.terminalIds.map((id) => id.trim()).filter((id) => id.length > 0)), | ||
| 489 | ].filter((terminalId) => { | ||
| 490 | if (!validTerminalIdSet.has(terminalId)) return false; | ||
| 491 | if (assignedTerminalIds.has(terminalId)) return false; | ||
| 492 | return true; | ||
| 493 | }); | ||
| 494 | if (nextTerminalIds.length === 0) continue; | ||
| 495 | |||
| 496 | for (const terminalId of nextTerminalIds) { | ||
| 497 | assignedTerminalIds.add(terminalId); | ||
| 498 | } | ||
| 499 | |||
| 500 | const baseGroupId = | ||
| 501 | terminalGroup.id.trim().length > 0 | ||
| 502 | ? terminalGroup.id.trim() | ||
| 503 | : `group-${nextTerminalIds[0] ?? DEFAULT_THREAD_TERMINAL_ID}`; | ||
| 504 | nextGroups.push({ | ||
| 505 | id: assignUniqueGroupId(baseGroupId), | ||
| 506 | terminalIds: nextTerminalIds, | ||
| 507 | }); | ||
| 472 | } | 508 | } |
| 473 | 509 | ||
| 474 | const fallbackPartner = normalizedTerminalIds.find((id) => id !== resolvedActiveTerminalId); | 510 | for (const terminalId of normalizedTerminalIds) { |
| 475 | if (!fallbackPartner) { | 511 | if (assignedTerminalIds.has(terminalId)) continue; |
| 476 | return []; | 512 | nextGroups.push({ |
| 513 | id: assignUniqueGroupId(`group-${terminalId}`), | ||
| 514 | terminalIds: [terminalId], | ||
| 515 | }); | ||
| 477 | } | 516 | } |
| 478 | return [resolvedActiveTerminalId, fallbackPartner]; | ||
| 479 | }, [normalizedTerminalIds, resolvedActiveTerminalId, splitTerminalIds, terminalLayout]); | ||
| 480 | 517 | ||
| 481 | const visibleTerminalIds = | 518 | if (nextGroups.length > 0) { |
| 482 | terminalLayout === "split" && resolvedSplitTerminalIds.length === 2 | 519 | return nextGroups; |
| 483 | ? resolvedSplitTerminalIds | 520 | } |
| 484 | : [resolvedActiveTerminalId]; | ||
| 485 | 521 | ||
| 486 | const showTabBar = terminalLayout === "tabs" && normalizedTerminalIds.length > 1; | 522 | return [ |
| 523 | { | ||
| 524 | id: `group-${resolvedActiveTerminalId}`, | ||
| 525 | terminalIds: [resolvedActiveTerminalId], | ||
| 526 | }, | ||
| 527 | ]; | ||
| 528 | }, [normalizedTerminalIds, resolvedActiveTerminalId, terminalGroups]); | ||
| 529 | |||
| 530 | const resolvedActiveGroupIndex = useMemo(() => { | ||
| 531 | const indexById = resolvedTerminalGroups.findIndex( | ||
| 532 | (terminalGroup) => terminalGroup.id === activeTerminalGroupId, | ||
| 533 | ); | ||
| 534 | if (indexById >= 0) return indexById; | ||
| 535 | const indexByTerminal = resolvedTerminalGroups.findIndex((terminalGroup) => | ||
| 536 | terminalGroup.terminalIds.includes(resolvedActiveTerminalId), | ||
| 537 | ); | ||
| 538 | return indexByTerminal >= 0 ? indexByTerminal : 0; | ||
| 539 | }, [activeTerminalGroupId, resolvedActiveTerminalId, resolvedTerminalGroups]); | ||
| 540 | |||
| 541 | const visibleTerminalIds = | ||
| 542 | resolvedTerminalGroups[resolvedActiveGroupIndex]?.terminalIds ?? [resolvedActiveTerminalId]; | ||
| 543 | const hasTerminalSidebar = normalizedTerminalIds.length > 1; | ||
| 544 | const isSplitView = visibleTerminalIds.length > 1; | ||
| 545 | const showGroupHeaders = | ||
| 546 | resolvedTerminalGroups.length > 1 || | ||
| 547 | resolvedTerminalGroups.some((terminalGroup) => terminalGroup.terminalIds.length > 1); | ||
| 487 | const terminalLabelById = useMemo( | 548 | const terminalLabelById = useMemo( |
| 488 | () => | 549 | () => |
| 489 | new Map( | 550 | new Map( |
| ... | @@ -585,147 +646,218 @@ export default function ThreadTerminalDrawer({ | ... | @@ -585,147 +646,218 @@ export default function ThreadTerminalDrawer({ |
| 585 | 646 | ||
| 586 | return ( | 647 | return ( |
| 587 | <aside | 648 | <aside |
| 588 | className="thread-terminal-drawer flex shrink-0 flex-col border-t border-border/80 bg-background" | 649 | className="thread-terminal-drawer relative flex shrink-0 flex-col border-t border-border/80 bg-background" |
| 589 | style={{ height: `${drawerHeight}px` }} | 650 | style={{ height: `${drawerHeight}px` }} |
| 590 | > | 651 | > |
| 591 | <div | 652 | <div |
| 592 | className="flex h-2 cursor-row-resize items-center justify-center" | 653 | className="absolute inset-x-0 top-0 z-20 h-1.5 cursor-row-resize" |
| 593 | onPointerDown={handleResizePointerDown} | 654 | onPointerDown={handleResizePointerDown} |
| 594 | onPointerMove={handleResizePointerMove} | 655 | onPointerMove={handleResizePointerMove} |
| 595 | onPointerUp={handleResizePointerEnd} | 656 | onPointerUp={handleResizePointerEnd} |
| 596 | onPointerCancel={handleResizePointerEnd} | 657 | onPointerCancel={handleResizePointerEnd} |
| 597 | > | 658 | /> |
| 598 | <div className="h-px w-10 rounded-full bg-border/80" /> | 659 | |
| 599 | </div> | 660 | {!hasTerminalSidebar && ( |
| 661 | <div className="pointer-events-none absolute right-2 top-2 z-20"> | ||
| 662 | <div className="pointer-events-auto inline-flex items-center overflow-hidden rounded-md border border-border/80 bg-background/70"> | ||
| 663 | <button | ||
| 664 | type="button" | ||
| 665 | className="p-1 text-foreground/90 transition-colors hover:bg-accent" | ||
| 666 | onClick={onSplitTerminal} | ||
| 667 | aria-label="Split Terminal" | ||
| 668 | title="Split Terminal" | ||
| 669 | > | ||
| 670 | <SquareSplitHorizontal className="size-3.25" /> | ||
| 671 | </button> | ||
| 672 | <div className="h-4 w-px bg-border/80" /> | ||
| 673 | <button | ||
| 674 | type="button" | ||
| 675 | className="p-1 text-foreground/90 transition-colors hover:bg-accent" | ||
| 676 | onClick={onNewTerminal} | ||
| 677 | aria-label="New Terminal" | ||
| 678 | title="New Terminal" | ||
| 679 | > | ||
| 680 | <Plus className="size-3.25" /> | ||
| 681 | </button> | ||
| 682 | <div className="h-4 w-px bg-border/80" /> | ||
| 683 | <button | ||
| 684 | type="button" | ||
| 685 | className="p-1 text-foreground/90 transition-colors hover:bg-accent" | ||
| 686 | onClick={() => onCloseTerminal(resolvedActiveTerminalId)} | ||
| 687 | aria-label="Close Terminal" | ||
| 688 | title="Close Terminal" | ||
| 689 | > | ||
| 690 | <Trash2 className="size-3.25" /> | ||
| 691 | </button> | ||
| 692 | </div> | ||
| 693 | </div> | ||
| 694 | )} | ||
| 600 | 695 | ||
| 601 | <div className="flex items-center gap-2 px-2 pb-1"> | 696 | <div className="min-h-0 w-full flex-1"> |
| 602 | {showTabBar ? ( | 697 | <div className={`flex h-full min-h-0 ${hasTerminalSidebar ? "gap-1.5" : ""}`}> |
| 603 | <div className="min-w-0 flex-1 overflow-x-auto"> | 698 | <div className="min-w-0 flex-1"> |
| 604 | <div className="inline-flex rounded-md border border-border/80 bg-muted/40 p-0.5"> | 699 | {isSplitView ? ( |
| 605 | {normalizedTerminalIds.map((terminalId) => { | 700 | <div |
| 606 | const isActive = terminalId === resolvedActiveTerminalId; | 701 | className="grid h-full w-full gap-0 overflow-x-auto" |
| 607 | return ( | 702 | style={{ |
| 703 | gridTemplateColumns: `repeat(${visibleTerminalIds.length}, minmax(260px, 1fr))`, | ||
| 704 | }} | ||
| 705 | > | ||
| 706 | {visibleTerminalIds.map((terminalId) => ( | ||
| 608 | <div | 707 | <div |
| 609 | key={terminalId} | 708 | key={terminalId} |
| 610 | className={`inline-flex items-center gap-1 rounded px-2.5 py-1 text-xs transition-colors ${ | 709 | className={`min-h-0 min-w-0 border-l first:border-l-0 ${ |
| 611 | isActive | 710 | terminalId === resolvedActiveTerminalId |
| 612 | ? "bg-background text-foreground shadow-sm" | 711 | ? "border-border" |
| 613 | : "text-muted-foreground" | 712 | : "border-border/70" |
| 614 | }`} | 713 | }`} |
| 714 | onMouseDown={() => { | ||
| 715 | if (terminalId !== resolvedActiveTerminalId) { | ||
| 716 | onActiveTerminalChange(terminalId); | ||
| 717 | } | ||
| 718 | }} | ||
| 615 | > | 719 | > |
| 616 | <button | 720 | <div className="h-full p-1"> |
| 617 | type="button" | 721 | <TerminalViewport |
| 618 | className="pr-1 hover:text-foreground" | 722 | api={api} |
| 619 | onClick={() => onActiveTerminalChange(terminalId)} | 723 | threadId={threadId} |
| 620 | > | 724 | terminalId={terminalId} |
| 621 | {terminalLabelById.get(terminalId) ?? "Terminal"} | 725 | cwd={cwd} |
| 622 | </button> | 726 | focusRequestId={focusRequestId} |
| 623 | <button | 727 | autoFocus={terminalId === resolvedActiveTerminalId} |
| 624 | type="button" | 728 | resizeEpoch={resizeEpoch} |
| 625 | className="rounded px-1 text-[11px] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground" | 729 | drawerHeight={drawerHeight} |
| 626 | onClick={(event) => { | 730 | /> |
| 627 | event.stopPropagation(); | 731 | </div> |
| 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> | 732 | </div> |
| 636 | ); | 733 | ))} |
| 637 | })} | 734 | </div> |
| 638 | </div> | 735 | ) : ( |
| 736 | <div className="h-full p-1"> | ||
| 737 | <TerminalViewport | ||
| 738 | key={resolvedActiveTerminalId} | ||
| 739 | api={api} | ||
| 740 | threadId={threadId} | ||
| 741 | terminalId={resolvedActiveTerminalId} | ||
| 742 | cwd={cwd} | ||
| 743 | focusRequestId={focusRequestId} | ||
| 744 | autoFocus | ||
| 745 | resizeEpoch={resizeEpoch} | ||
| 746 | drawerHeight={drawerHeight} | ||
| 747 | /> | ||
| 748 | </div> | ||
| 749 | )} | ||
| 639 | </div> | 750 | </div> |
| 640 | ) : ( | ||
| 641 | <div className="flex-1" /> | ||
| 642 | )} | ||
| 643 | |||
| 644 | <div className="inline-flex items-center overflow-hidden rounded-md border border-border/80 bg-muted/30"> | ||
| 645 | <button | ||
| 646 | type="button" | ||
| 647 | className="px-2.5 py-1 text-xs text-foreground/90 transition-colors hover:bg-accent" | ||
| 648 | onClick={onSplitTerminal} | ||
| 649 | > | ||
| 650 | Split Terminal | ||
| 651 | </button> | ||
| 652 | <div className="h-5 w-px bg-border/80" /> | ||
| 653 | <button | ||
| 654 | type="button" | ||
| 655 | className="px-2.5 py-1 text-xs text-foreground/90 transition-colors hover:bg-accent" | ||
| 656 | onClick={onNewTerminal} | ||
| 657 | > | ||
| 658 | New Terminal | ||
| 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> | ||
| 668 | </div> | ||
| 669 | </div> | ||
| 670 | 751 | ||
| 671 | <div className="min-h-0 w-full flex-1 px-1.5 pb-1"> | 752 | {hasTerminalSidebar && ( |
| 672 | {terminalLayout === "split" && visibleTerminalIds.length === 2 ? ( | 753 | <aside className="flex w-36 min-w-36 flex-col border border-border/70 bg-muted/10"> |
| 673 | <div className="grid h-full w-full grid-cols-2 gap-1"> | 754 | <div className="flex h-[22px] items-stretch justify-end border-b border-border/70"> |
| 674 | {visibleTerminalIds.map((terminalId) => ( | 755 | <div className="inline-flex h-full items-stretch"> |
| 675 | <div | ||
| 676 | key={terminalId} | ||
| 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 | 756 | <button |
| 681 | type="button" | 757 | type="button" |
| 682 | className={`text-xs ${ | 758 | className="inline-flex h-full items-center px-1 text-foreground/90 transition-colors hover:bg-accent/70" |
| 683 | terminalId === resolvedActiveTerminalId | 759 | onClick={onSplitTerminal} |
| 684 | ? "text-foreground" | 760 | aria-label="Split Terminal" |
| 685 | : "text-muted-foreground hover:text-foreground" | 761 | title="Split Terminal" |
| 686 | }`} | ||
| 687 | onClick={() => onActiveTerminalChange(terminalId)} | ||
| 688 | > | 762 | > |
| 689 | {terminalLabelById.get(terminalId) ?? "Terminal"} | 763 | <SquareSplitHorizontal className="size-3.25" /> |
| 690 | </button> | 764 | </button> |
| 691 | <button | 765 | <button |
| 692 | type="button" | 766 | type="button" |
| 693 | className="rounded px-1 text-xs text-muted-foreground transition-colors hover:bg-accent hover:text-foreground" | 767 | className="inline-flex h-full items-center border-l border-border/70 px-1 text-foreground/90 transition-colors hover:bg-accent/70" |
| 694 | onClick={() => onCloseTerminal(terminalId)} | 768 | onClick={onNewTerminal} |
| 695 | aria-label={`Close ${terminalLabelById.get(terminalId) ?? "terminal"}`} | 769 | aria-label="New Terminal" |
| 696 | title={`Close ${terminalLabelById.get(terminalId) ?? "terminal"}`} | 770 | title="New Terminal" |
| 697 | > | 771 | > |
| 698 | × | 772 | <Plus className="size-3.25" /> |
| 773 | </button> | ||
| 774 | <button | ||
| 775 | type="button" | ||
| 776 | className="inline-flex h-full items-center border-l border-border/70 px-1 text-foreground/90 transition-colors hover:bg-accent/70" | ||
| 777 | onClick={() => onCloseTerminal(resolvedActiveTerminalId)} | ||
| 778 | aria-label="Close Terminal" | ||
| 779 | title="Close Terminal" | ||
| 780 | > | ||
| 781 | <Trash2 className="size-3.25" /> | ||
| 699 | </button> | 782 | </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> | 783 | </div> |
| 713 | </div> | 784 | </div> |
| 714 | ))} | 785 | |
| 715 | </div> | 786 | <div className="min-h-0 flex-1 overflow-y-auto px-1 py-1"> |
| 716 | ) : ( | 787 | {resolvedTerminalGroups.map((terminalGroup, groupIndex) => { |
| 717 | <TerminalViewport | 788 | const isGroupActive = terminalGroup.terminalIds.includes(resolvedActiveTerminalId); |
| 718 | key={resolvedActiveTerminalId} | 789 | const groupActiveTerminalId = isGroupActive |
| 719 | api={api} | 790 | ? resolvedActiveTerminalId |
| 720 | threadId={threadId} | 791 | : (terminalGroup.terminalIds[0] ?? resolvedActiveTerminalId); |
| 721 | terminalId={resolvedActiveTerminalId} | 792 | |
| 722 | cwd={cwd} | 793 | return ( |
| 723 | focusRequestId={focusRequestId} | 794 | <div key={terminalGroup.id} className="pb-0.5"> |
| 724 | autoFocus | 795 | {showGroupHeaders && ( |
| 725 | resizeEpoch={resizeEpoch} | 796 | <button |
| 726 | drawerHeight={drawerHeight} | 797 | type="button" |
| 727 | /> | 798 | className={`flex w-full items-center rounded px-1 py-0.5 text-[10px] uppercase tracking-[0.08em] ${ |
| 728 | )} | 799 | isGroupActive |
| 800 | ? "bg-accent/70 text-foreground" | ||
| 801 | : "text-muted-foreground hover:bg-accent/50 hover:text-foreground" | ||
| 802 | }`} | ||
| 803 | onClick={() => onActiveTerminalChange(groupActiveTerminalId)} | ||
| 804 | > | ||
| 805 | {terminalGroup.terminalIds.length > 1 | ||
| 806 | ? `Split ${groupIndex + 1}` | ||
| 807 | : `Terminal ${groupIndex + 1}`} | ||
| 808 | </button> | ||
| 809 | )} | ||
| 810 | |||
| 811 | <div className={showGroupHeaders ? "ml-1 border-l border-border/60 pl-1.5" : ""}> | ||
| 812 | {terminalGroup.terminalIds.map((terminalId) => { | ||
| 813 | const isActive = terminalId === resolvedActiveTerminalId; | ||
| 814 | return ( | ||
| 815 | <div | ||
| 816 | key={terminalId} | ||
| 817 | className={`group flex items-center gap-1 rounded px-1 py-0.5 text-[11px] ${ | ||
| 818 | isActive | ||
| 819 | ? "bg-accent text-foreground" | ||
| 820 | : "text-muted-foreground hover:bg-accent/50 hover:text-foreground" | ||
| 821 | }`} | ||
| 822 | > | ||
| 823 | {showGroupHeaders && ( | ||
| 824 | <span className="text-[10px] text-muted-foreground/80">└</span> | ||
| 825 | )} | ||
| 826 | <button | ||
| 827 | type="button" | ||
| 828 | className="flex min-w-0 flex-1 items-center gap-1 text-left" | ||
| 829 | onClick={() => onActiveTerminalChange(terminalId)} | ||
| 830 | > | ||
| 831 | <TerminalSquare className="size-3 shrink-0" /> | ||
| 832 | <span className="truncate"> | ||
| 833 | {terminalLabelById.get(terminalId) ?? "Terminal"} | ||
| 834 | </span> | ||
| 835 | </button> | ||
| 836 | {normalizedTerminalIds.length > 1 && ( | ||
| 837 | <button | ||
| 838 | type="button" | ||
| 839 | className="rounded px-1 text-xs font-medium leading-none text-muted-foreground opacity-0 transition hover:bg-accent hover:text-foreground group-hover:opacity-100" | ||
| 840 | onClick={(event) => { | ||
| 841 | event.stopPropagation(); | ||
| 842 | onCloseTerminal(terminalId); | ||
| 843 | }} | ||
| 844 | aria-label={`Close ${terminalLabelById.get(terminalId) ?? "terminal"}`} | ||
| 845 | title={`Close ${terminalLabelById.get(terminalId) ?? "terminal"}`} | ||
| 846 | > | ||
| 847 | × | ||
| 848 | </button> | ||
| 849 | )} | ||
| 850 | </div> | ||
| 851 | ); | ||
| 852 | })} | ||
| 853 | </div> | ||
| 854 | </div> | ||
| 855 | ); | ||
| 856 | })} | ||
| 857 | </div> | ||
| 858 | </aside> | ||
| 859 | )} | ||
| 860 | </div> | ||
| 729 | </div> | 861 | </div> |
| 730 | </aside> | 862 | </aside> |
| 731 | ); | 863 | ); |
apps/web/src/persistenceSchema.test.ts+82-14| ... | @@ -52,8 +52,12 @@ describe("hydratePersistedState", () => { | ... | @@ -52,8 +52,12 @@ describe("hydratePersistedState", () => { |
| 52 | expect(hydrated?.threads[0]?.terminalHeight).toBe(DEFAULT_THREAD_TERMINAL_HEIGHT); | 52 | expect(hydrated?.threads[0]?.terminalHeight).toBe(DEFAULT_THREAD_TERMINAL_HEIGHT); |
| 53 | expect(hydrated?.threads[0]?.terminalIds).toEqual([DEFAULT_THREAD_TERMINAL_ID]); | 53 | expect(hydrated?.threads[0]?.terminalIds).toEqual([DEFAULT_THREAD_TERMINAL_ID]); |
| 54 | expect(hydrated?.threads[0]?.activeTerminalId).toBe(DEFAULT_THREAD_TERMINAL_ID); | 54 | expect(hydrated?.threads[0]?.activeTerminalId).toBe(DEFAULT_THREAD_TERMINAL_ID); |
| 55 | expect(hydrated?.threads[0]?.terminalLayout).toBe("single"); | 55 | expect(hydrated?.threads[0]?.terminalGroups).toEqual([ |
| 56 | expect(hydrated?.threads[0]?.splitTerminalIds).toEqual([]); | 56 | { id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, terminalIds: [DEFAULT_THREAD_TERMINAL_ID] }, |
| 57 | ]); | ||
| 58 | expect(hydrated?.threads[0]?.activeTerminalGroupId).toBe( | ||
| 59 | `group-${DEFAULT_THREAD_TERMINAL_ID}`, | ||
| 60 | ); | ||
| 57 | expect(hydrated?.threads[0]?.messages[0]?.streaming).toBe(false); | 61 | expect(hydrated?.threads[0]?.messages[0]?.streaming).toBe(false); |
| 58 | expect(hydrated?.runtimeMode).toBe("full-access"); | 62 | expect(hydrated?.runtimeMode).toBe("full-access"); |
| 59 | }); | 63 | }); |
| ... | @@ -98,8 +102,12 @@ describe("hydratePersistedState", () => { | ... | @@ -98,8 +102,12 @@ describe("hydratePersistedState", () => { |
| 98 | expect(hydrated?.threads[0]?.terminalHeight).toBe(DEFAULT_THREAD_TERMINAL_HEIGHT); | 102 | expect(hydrated?.threads[0]?.terminalHeight).toBe(DEFAULT_THREAD_TERMINAL_HEIGHT); |
| 99 | expect(hydrated?.threads[0]?.terminalIds).toEqual([DEFAULT_THREAD_TERMINAL_ID]); | 103 | expect(hydrated?.threads[0]?.terminalIds).toEqual([DEFAULT_THREAD_TERMINAL_ID]); |
| 100 | expect(hydrated?.threads[0]?.activeTerminalId).toBe(DEFAULT_THREAD_TERMINAL_ID); | 104 | expect(hydrated?.threads[0]?.activeTerminalId).toBe(DEFAULT_THREAD_TERMINAL_ID); |
| 101 | expect(hydrated?.threads[0]?.terminalLayout).toBe("single"); | 105 | expect(hydrated?.threads[0]?.terminalGroups).toEqual([ |
| 102 | expect(hydrated?.threads[0]?.splitTerminalIds).toEqual([]); | 106 | { id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, terminalIds: [DEFAULT_THREAD_TERMINAL_ID] }, |
| 107 | ]); | ||
| 108 | expect(hydrated?.threads[0]?.activeTerminalGroupId).toBe( | ||
| 109 | `group-${DEFAULT_THREAD_TERMINAL_ID}`, | ||
| 110 | ); | ||
| 103 | expect(hydrated?.activeThreadId).toBe("t-1"); | 111 | expect(hydrated?.activeThreadId).toBe("t-1"); |
| 104 | expect(hydrated?.runtimeMode).toBe("full-access"); | 112 | expect(hydrated?.runtimeMode).toBe("full-access"); |
| 105 | }); | 113 | }); |
| ... | @@ -125,7 +133,7 @@ describe("hydratePersistedState", () => { | ... | @@ -125,7 +133,7 @@ describe("hydratePersistedState", () => { |
| 125 | expect(hydrated?.runtimeMode).toBe("approval-required"); | 133 | expect(hydrated?.runtimeMode).toBe("approval-required"); |
| 126 | }); | 134 | }); |
| 127 | 135 | ||
| 128 | it("hydrates terminal fields from v6 payload", () => { | 136 | it("hydrates terminal fields from legacy v6 payload", () => { |
| 129 | const payload = JSON.stringify({ | 137 | const payload = JSON.stringify({ |
| 130 | version: 6, | 138 | version: 6, |
| 131 | runtimeMode: "full-access", | 139 | runtimeMode: "full-access", |
| ... | @@ -164,11 +172,60 @@ describe("hydratePersistedState", () => { | ... | @@ -164,11 +172,60 @@ describe("hydratePersistedState", () => { |
| 164 | expect(hydrated?.threads[0]?.terminalHeight).toBe(360); | 172 | expect(hydrated?.threads[0]?.terminalHeight).toBe(360); |
| 165 | expect(hydrated?.threads[0]?.terminalIds).toEqual([DEFAULT_THREAD_TERMINAL_ID, "term-2"]); | 173 | expect(hydrated?.threads[0]?.terminalIds).toEqual([DEFAULT_THREAD_TERMINAL_ID, "term-2"]); |
| 166 | expect(hydrated?.threads[0]?.activeTerminalId).toBe("term-2"); | 174 | expect(hydrated?.threads[0]?.activeTerminalId).toBe("term-2"); |
| 167 | expect(hydrated?.threads[0]?.terminalLayout).toBe("tabs"); | 175 | expect(hydrated?.threads[0]?.terminalGroups).toEqual([ |
| 168 | expect(hydrated?.threads[0]?.splitTerminalIds).toEqual([]); | 176 | { id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, terminalIds: [DEFAULT_THREAD_TERMINAL_ID] }, |
| 177 | { id: "group-term-2", terminalIds: ["term-2"] }, | ||
| 178 | ]); | ||
| 179 | expect(hydrated?.threads[0]?.activeTerminalGroupId).toBe("group-term-2"); | ||
| 169 | expect(hydrated?.threads[0]?.lastVisitedAt).toBe("2026-02-08T10:01:00.000Z"); | 180 | expect(hydrated?.threads[0]?.lastVisitedAt).toBe("2026-02-08T10:01:00.000Z"); |
| 170 | }); | 181 | }); |
| 171 | 182 | ||
| 183 | it("hydrates legacy split layout into a grouped split", () => { | ||
| 184 | const payload = JSON.stringify({ | ||
| 185 | version: 6, | ||
| 186 | runtimeMode: "full-access", | ||
| 187 | projects: [ | ||
| 188 | { | ||
| 189 | id: "p-1", | ||
| 190 | name: "Project", | ||
| 191 | cwd: "/tmp/project", | ||
| 192 | model: "gpt-5.3-codex", | ||
| 193 | expanded: true, | ||
| 194 | }, | ||
| 195 | ], | ||
| 196 | threads: [ | ||
| 197 | { | ||
| 198 | id: "t-1", | ||
| 199 | codexThreadId: null, | ||
| 200 | projectId: "p-1", | ||
| 201 | title: "Thread", | ||
| 202 | model: "gpt-5.3-codex", | ||
| 203 | terminalOpen: true, | ||
| 204 | terminalHeight: 360, | ||
| 205 | terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2", "term-3"], | ||
| 206 | activeTerminalId: "term-2", | ||
| 207 | terminalLayout: "split", | ||
| 208 | splitTerminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2"], | ||
| 209 | messages: [], | ||
| 210 | createdAt: "2026-02-08T10:00:00.000Z", | ||
| 211 | }, | ||
| 212 | ], | ||
| 213 | activeThreadId: "t-1", | ||
| 214 | }); | ||
| 215 | |||
| 216 | const hydrated = hydratePersistedState(payload, false); | ||
| 217 | expect(hydrated?.threads[0]?.terminalGroups).toEqual([ | ||
| 218 | { | ||
| 219 | id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, | ||
| 220 | terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2"], | ||
| 221 | }, | ||
| 222 | { id: "group-term-3", terminalIds: ["term-3"] }, | ||
| 223 | ]); | ||
| 224 | expect(hydrated?.threads[0]?.activeTerminalGroupId).toBe( | ||
| 225 | `group-${DEFAULT_THREAD_TERMINAL_ID}`, | ||
| 226 | ); | ||
| 227 | }); | ||
| 228 | |||
| 172 | it("defaults terminalHeight when hydrating v5 payloads", () => { | 229 | it("defaults terminalHeight when hydrating v5 payloads", () => { |
| 173 | const payload = JSON.stringify({ | 230 | const payload = JSON.stringify({ |
| 174 | version: 5, | 231 | version: 5, |
| ... | @@ -201,12 +258,17 @@ describe("hydratePersistedState", () => { | ... | @@ -201,12 +258,17 @@ describe("hydratePersistedState", () => { |
| 201 | expect(hydrated?.threads[0]?.terminalHeight).toBe(DEFAULT_THREAD_TERMINAL_HEIGHT); | 258 | expect(hydrated?.threads[0]?.terminalHeight).toBe(DEFAULT_THREAD_TERMINAL_HEIGHT); |
| 202 | expect(hydrated?.threads[0]?.terminalIds).toEqual([DEFAULT_THREAD_TERMINAL_ID]); | 259 | expect(hydrated?.threads[0]?.terminalIds).toEqual([DEFAULT_THREAD_TERMINAL_ID]); |
| 203 | expect(hydrated?.threads[0]?.activeTerminalId).toBe(DEFAULT_THREAD_TERMINAL_ID); | 260 | expect(hydrated?.threads[0]?.activeTerminalId).toBe(DEFAULT_THREAD_TERMINAL_ID); |
| 204 | expect(hydrated?.threads[0]?.terminalLayout).toBe("single"); | 261 | expect(hydrated?.threads[0]?.terminalGroups).toEqual([ |
| 262 | { id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, terminalIds: [DEFAULT_THREAD_TERMINAL_ID] }, | ||
| 263 | ]); | ||
| 264 | expect(hydrated?.threads[0]?.activeTerminalGroupId).toBe( | ||
| 265 | `group-${DEFAULT_THREAD_TERMINAL_ID}`, | ||
| 266 | ); | ||
| 205 | }); | 267 | }); |
| 206 | }); | 268 | }); |
| 207 | 269 | ||
| 208 | describe("toPersistedState", () => { | 270 | describe("toPersistedState", () => { |
| 209 | it("writes v6 payload and strips non-persisted thread fields", () => { | 271 | it("writes v7 payload and strips non-persisted thread fields", () => { |
| 210 | const thread: Thread = { | 272 | const thread: Thread = { |
| 211 | id: "t-1", | 273 | id: "t-1", |
| 212 | codexThreadId: "thr_1", | 274 | codexThreadId: "thr_1", |
| ... | @@ -217,8 +279,11 @@ describe("toPersistedState", () => { | ... | @@ -217,8 +279,11 @@ describe("toPersistedState", () => { |
| 217 | terminalHeight: 320, | 279 | terminalHeight: 320, |
| 218 | terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2"], | 280 | terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2"], |
| 219 | activeTerminalId: "term-2", | 281 | activeTerminalId: "term-2", |
| 220 | terminalLayout: "tabs", | 282 | terminalGroups: [ |
| 221 | splitTerminalIds: [], | 283 | { id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, terminalIds: [DEFAULT_THREAD_TERMINAL_ID] }, |
| 284 | { id: "group-term-2", terminalIds: ["term-2"] }, | ||
| 285 | ], | ||
| 286 | activeTerminalGroupId: "group-term-2", | ||
| 222 | session: null, | 287 | session: null, |
| 223 | messages: [ | 288 | messages: [ |
| 224 | { | 289 | { |
| ... | @@ -262,7 +327,7 @@ describe("toPersistedState", () => { | ... | @@ -262,7 +327,7 @@ describe("toPersistedState", () => { |
| 262 | runtimeMode: "full-access", | 327 | runtimeMode: "full-access", |
| 263 | }); | 328 | }); |
| 264 | 329 | ||
| 265 | expect(persisted.version).toBe(6); | 330 | expect(persisted.version).toBe(7); |
| 266 | expect(persisted.runtimeMode).toBe("full-access"); | 331 | expect(persisted.runtimeMode).toBe("full-access"); |
| 267 | expect(persisted.threads[0]).toEqual({ | 332 | expect(persisted.threads[0]).toEqual({ |
| 268 | id: "t-1", | 333 | id: "t-1", |
| ... | @@ -274,8 +339,11 @@ describe("toPersistedState", () => { | ... | @@ -274,8 +339,11 @@ describe("toPersistedState", () => { |
| 274 | terminalHeight: 320, | 339 | terminalHeight: 320, |
| 275 | terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2"], | 340 | terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2"], |
| 276 | activeTerminalId: "term-2", | 341 | activeTerminalId: "term-2", |
| 277 | terminalLayout: "tabs", | 342 | terminalGroups: [ |
| 278 | splitTerminalIds: [], | 343 | { id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, terminalIds: [DEFAULT_THREAD_TERMINAL_ID] }, |
| 344 | { id: "group-term-2", terminalIds: ["term-2"] }, | ||
| 345 | ], | ||
| 346 | activeTerminalGroupId: "group-term-2", | ||
| 279 | messages: [ | 347 | messages: [ |
| 280 | { | 348 | { |
| 281 | id: "m-1", | 349 | id: "m-1", |
apps/web/src/persistenceSchema.ts+99-26| ... | @@ -8,6 +8,7 @@ import { | ... | @@ -8,6 +8,7 @@ import { |
| 8 | type Project, | 8 | type Project, |
| 9 | type RuntimeMode, | 9 | type RuntimeMode, |
| 10 | type Thread, | 10 | type Thread, |
| 11 | type ThreadTerminalGroup, | ||
| 11 | } from "./types"; | 12 | } from "./types"; |
| 12 | 13 | ||
| 13 | const LEGACY_DEFAULT_MODEL = "gpt-5.2-codex"; | 14 | const LEGACY_DEFAULT_MODEL = "gpt-5.2-codex"; |
| ... | @@ -39,6 +40,11 @@ const persistedMessageSchema = z.object({ | ... | @@ -39,6 +40,11 @@ const persistedMessageSchema = z.object({ |
| 39 | streaming: z.boolean(), | 40 | streaming: z.boolean(), |
| 40 | }); | 41 | }); |
| 41 | 42 | ||
| 43 | const persistedTerminalGroupSchema = z.object({ | ||
| 44 | id: z.string().trim().min(1), | ||
| 45 | terminalIds: z.array(z.string().trim().min(1)), | ||
| 46 | }); | ||
| 47 | |||
| 42 | const persistedThreadSchema = z.object({ | 48 | const persistedThreadSchema = z.object({ |
| 43 | id: z.string().min(1), | 49 | id: z.string().min(1), |
| 44 | codexThreadId: z.string().min(1).nullable().default(null), | 50 | codexThreadId: z.string().min(1).nullable().default(null), |
| ... | @@ -51,8 +57,11 @@ const persistedThreadSchema = z.object({ | ... | @@ -51,8 +57,11 @@ const persistedThreadSchema = z.object({ |
| 51 | ), | 57 | ), |
| 52 | terminalIds: z.array(z.string().trim().min(1)).default([DEFAULT_THREAD_TERMINAL_ID]), | 58 | terminalIds: z.array(z.string().trim().min(1)).default([DEFAULT_THREAD_TERMINAL_ID]), |
| 53 | activeTerminalId: z.string().trim().min(1).default(DEFAULT_THREAD_TERMINAL_ID), | 59 | activeTerminalId: z.string().trim().min(1).default(DEFAULT_THREAD_TERMINAL_ID), |
| 54 | terminalLayout: z.enum(["single", "split", "tabs"]).default("single"), | 60 | terminalGroups: z.array(persistedTerminalGroupSchema).default([]), |
| 55 | splitTerminalIds: z.array(z.string().trim().min(1)).default([]), | 61 | activeTerminalGroupId: z.string().trim().min(1).optional(), |
| 62 | // Legacy v6 and older fields retained for migration. | ||
| 63 | terminalLayout: z.enum(["single", "split", "tabs"]).optional(), | ||
| 64 | splitTerminalIds: z.array(z.string().trim().min(1)).optional(), | ||
| 56 | messages: z.array(persistedMessageSchema), | 65 | messages: z.array(persistedMessageSchema), |
| 57 | createdAt: z.string().min(1), | 66 | createdAt: z.string().min(1), |
| 58 | lastVisitedAt: z.string().min(1).optional(), | 67 | lastVisitedAt: z.string().min(1).optional(), |
| ... | @@ -91,12 +100,18 @@ export const persistedStateV6Schema = persistedStateBodySchema.extend({ | ... | @@ -91,12 +100,18 @@ export const persistedStateV6Schema = persistedStateBodySchema.extend({ |
| 91 | version: z.literal(6).optional(), | 100 | version: z.literal(6).optional(), |
| 92 | }); | 101 | }); |
| 93 | 102 | ||
| 103 | export const persistedStateV7Schema = persistedStateBodySchema.extend({ | ||
| 104 | runtimeMode: runtimeModeSchema.default(DEFAULT_RUNTIME_MODE), | ||
| 105 | version: z.literal(7).optional(), | ||
| 106 | }); | ||
| 107 | |||
| 94 | export const persistedStateV5Schema = persistedStateBodySchema.extend({ | 108 | export const persistedStateV5Schema = persistedStateBodySchema.extend({ |
| 95 | runtimeMode: runtimeModeSchema.default(DEFAULT_RUNTIME_MODE), | 109 | runtimeMode: runtimeModeSchema.default(DEFAULT_RUNTIME_MODE), |
| 96 | version: z.literal(5).optional(), | 110 | version: z.literal(5).optional(), |
| 97 | }); | 111 | }); |
| 98 | 112 | ||
| 99 | const persistedStateSchema = z.union([ | 113 | const persistedStateSchema = z.union([ |
| 114 | persistedStateV7Schema, | ||
| 100 | persistedStateV6Schema, | 115 | persistedStateV6Schema, |
| 101 | persistedStateV5Schema, | 116 | persistedStateV5Schema, |
| 102 | persistedStateV4Schema, | 117 | persistedStateV4Schema, |
| ... | @@ -134,33 +149,91 @@ function hydrateThread( | ... | @@ -134,33 +149,91 @@ function hydrateThread( |
| 134 | thread: z.infer<typeof persistedThreadSchema>, | 149 | thread: z.infer<typeof persistedThreadSchema>, |
| 135 | isLegacyPayload: boolean, | 150 | isLegacyPayload: boolean, |
| 136 | ): Thread { | 151 | ): Thread { |
| 137 | const terminalIds = [...new Set(thread.terminalIds)] | 152 | const terminalIds = [...new Set(thread.terminalIds.map((id) => id.trim()).filter((id) => id.length > 0))]; |
| 138 | .map((id) => id.trim()) | ||
| 139 | .filter((id) => id.length > 0); | ||
| 140 | const safeTerminalIds = | 153 | const safeTerminalIds = |
| 141 | terminalIds.length > 0 ? terminalIds : [DEFAULT_THREAD_TERMINAL_ID]; | 154 | terminalIds.length > 0 ? terminalIds : [DEFAULT_THREAD_TERMINAL_ID]; |
| 142 | const activeTerminalId = safeTerminalIds.includes(thread.activeTerminalId) | 155 | const activeTerminalId = safeTerminalIds.includes(thread.activeTerminalId) |
| 143 | ? thread.activeTerminalId | 156 | ? thread.activeTerminalId |
| 144 | : (safeTerminalIds[0] ?? DEFAULT_THREAD_TERMINAL_ID); | 157 | : (safeTerminalIds[0] ?? DEFAULT_THREAD_TERMINAL_ID); |
| 145 | let terminalLayout = thread.terminalLayout; | 158 | const safeTerminalIdSet = new Set(safeTerminalIds); |
| 146 | let splitTerminalIds = [...new Set(thread.splitTerminalIds)].filter((id) => | 159 | const assignedTerminalIds = new Set<string>(); |
| 147 | safeTerminalIds.includes(id), | 160 | const usedGroupIds = new Set<string>(); |
| 148 | ); | 161 | const normalizedGroups: ThreadTerminalGroup[] = []; |
| 149 | if (terminalLayout === "split") { | 162 | const assignUniqueGroupId = (groupId: string): string => { |
| 150 | splitTerminalIds = splitTerminalIds.slice(0, 2); | 163 | if (!usedGroupIds.has(groupId)) { |
| 151 | if (splitTerminalIds.length < 2) { | 164 | usedGroupIds.add(groupId); |
| 152 | terminalLayout = safeTerminalIds.length > 1 ? "tabs" : "single"; | 165 | return groupId; |
| 153 | splitTerminalIds = []; | 166 | } |
| 167 | let suffix = 2; | ||
| 168 | while (usedGroupIds.has(`${groupId}-${suffix}`)) { | ||
| 169 | suffix += 1; | ||
| 170 | } | ||
| 171 | const uniqueGroupId = `${groupId}-${suffix}`; | ||
| 172 | usedGroupIds.add(uniqueGroupId); | ||
| 173 | return uniqueGroupId; | ||
| 174 | }; | ||
| 175 | |||
| 176 | for (const terminalGroup of thread.terminalGroups) { | ||
| 177 | const nextTerminalIds = [ | ||
| 178 | ...new Set(terminalGroup.terminalIds.map((id) => id.trim()).filter((id) => id.length > 0)), | ||
| 179 | ].filter((terminalId) => { | ||
| 180 | if (!safeTerminalIdSet.has(terminalId)) return false; | ||
| 181 | if (assignedTerminalIds.has(terminalId)) return false; | ||
| 182 | return true; | ||
| 183 | }); | ||
| 184 | if (nextTerminalIds.length === 0) continue; | ||
| 185 | for (const terminalId of nextTerminalIds) { | ||
| 186 | assignedTerminalIds.add(terminalId); | ||
| 187 | } | ||
| 188 | const baseGroupId = | ||
| 189 | terminalGroup.id.trim().length > 0 | ||
| 190 | ? terminalGroup.id.trim() | ||
| 191 | : `group-${nextTerminalIds[0] ?? DEFAULT_THREAD_TERMINAL_ID}`; | ||
| 192 | normalizedGroups.push({ | ||
| 193 | id: assignUniqueGroupId(baseGroupId), | ||
| 194 | terminalIds: nextTerminalIds, | ||
| 195 | }); | ||
| 196 | } | ||
| 197 | |||
| 198 | if (normalizedGroups.length === 0 && thread.terminalLayout === "split") { | ||
| 199 | const splitTerminalIds = [ | ||
| 200 | ...new Set((thread.splitTerminalIds ?? []).map((id) => id.trim()).filter((id) => id.length > 0)), | ||
| 201 | ].filter((terminalId) => safeTerminalIdSet.has(terminalId)); | ||
| 202 | if (splitTerminalIds.length >= 2) { | ||
| 203 | const splitGroupTerminalIds = splitTerminalIds.slice(0, 2); | ||
| 204 | for (const terminalId of splitGroupTerminalIds) { | ||
| 205 | assignedTerminalIds.add(terminalId); | ||
| 206 | } | ||
| 207 | normalizedGroups.push({ | ||
| 208 | id: assignUniqueGroupId(`group-${splitGroupTerminalIds[0] ?? DEFAULT_THREAD_TERMINAL_ID}`), | ||
| 209 | terminalIds: splitGroupTerminalIds, | ||
| 210 | }); | ||
| 154 | } | 211 | } |
| 155 | } else { | ||
| 156 | splitTerminalIds = []; | ||
| 157 | } | 212 | } |
| 158 | if (terminalLayout === "single" && safeTerminalIds.length > 1) { | 213 | |
| 159 | terminalLayout = "tabs"; | 214 | for (const terminalId of safeTerminalIds) { |
| 160 | } else if (terminalLayout === "tabs" && safeTerminalIds.length < 2) { | 215 | if (assignedTerminalIds.has(terminalId)) continue; |
| 161 | terminalLayout = "single"; | 216 | normalizedGroups.push({ |
| 217 | id: assignUniqueGroupId(`group-${terminalId}`), | ||
| 218 | terminalIds: [terminalId], | ||
| 219 | }); | ||
| 162 | } | 220 | } |
| 163 | 221 | ||
| 222 | const activeGroupIndexFromId = normalizedGroups.findIndex( | ||
| 223 | (terminalGroup) => terminalGroup.id === thread.activeTerminalGroupId, | ||
| 224 | ); | ||
| 225 | const activeGroupIndexFromTerminal = normalizedGroups.findIndex((terminalGroup) => | ||
| 226 | terminalGroup.terminalIds.includes(activeTerminalId), | ||
| 227 | ); | ||
| 228 | const activeGroupIndex = | ||
| 229 | activeGroupIndexFromId >= 0 | ||
| 230 | ? activeGroupIndexFromId | ||
| 231 | : (activeGroupIndexFromTerminal >= 0 ? activeGroupIndexFromTerminal : 0); | ||
| 232 | const activeTerminalGroupId = | ||
| 233 | normalizedGroups[activeGroupIndex]?.id ?? | ||
| 234 | normalizedGroups[0]?.id ?? | ||
| 235 | `group-${DEFAULT_THREAD_TERMINAL_ID}`; | ||
| 236 | |||
| 164 | return { | 237 | return { |
| 165 | id: thread.id, | 238 | id: thread.id, |
| 166 | codexThreadId: thread.codexThreadId, | 239 | codexThreadId: thread.codexThreadId, |
| ... | @@ -171,8 +244,8 @@ function hydrateThread( | ... | @@ -171,8 +244,8 @@ function hydrateThread( |
| 171 | terminalHeight: thread.terminalHeight ?? DEFAULT_THREAD_TERMINAL_HEIGHT, | 244 | terminalHeight: thread.terminalHeight ?? DEFAULT_THREAD_TERMINAL_HEIGHT, |
| 172 | terminalIds: safeTerminalIds, | 245 | terminalIds: safeTerminalIds, |
| 173 | activeTerminalId, | 246 | activeTerminalId, |
| 174 | terminalLayout, | 247 | terminalGroups: normalizedGroups, |
| 175 | splitTerminalIds, | 248 | activeTerminalGroupId, |
| 176 | session: null, | 249 | session: null, |
| 177 | messages: thread.messages.map((message) => { | 250 | messages: thread.messages.map((message) => { |
| 178 | const hydratedAttachments = message.attachments?.map((attachment) => ({ ...attachment })); | 251 | const hydratedAttachments = message.attachments?.map((attachment) => ({ ...attachment })); |
| ... | @@ -235,9 +308,9 @@ export function hydratePersistedState( | ... | @@ -235,9 +308,9 @@ export function hydratePersistedState( |
| 235 | 308 | ||
| 236 | export function toPersistedState( | 309 | export function toPersistedState( |
| 237 | state: PersistedStoreSnapshot, | 310 | state: PersistedStoreSnapshot, |
| 238 | ): z.infer<typeof persistedStateV6Schema> { | 311 | ): z.infer<typeof persistedStateV7Schema> { |
| 239 | return { | 312 | return { |
| 240 | version: 6, | 313 | version: 7, |
| 241 | projects: state.projects, | 314 | projects: state.projects, |
| 242 | threads: state.threads.map((thread) => ({ | 315 | threads: state.threads.map((thread) => ({ |
| 243 | id: thread.id, | 316 | id: thread.id, |
| ... | @@ -249,8 +322,8 @@ export function toPersistedState( | ... | @@ -249,8 +322,8 @@ export function toPersistedState( |
| 249 | terminalHeight: thread.terminalHeight, | 322 | terminalHeight: thread.terminalHeight, |
| 250 | terminalIds: thread.terminalIds, | 323 | terminalIds: thread.terminalIds, |
| 251 | activeTerminalId: thread.activeTerminalId, | 324 | activeTerminalId: thread.activeTerminalId, |
| 252 | terminalLayout: thread.terminalLayout, | 325 | terminalGroups: thread.terminalGroups, |
| 253 | splitTerminalIds: thread.splitTerminalIds, | 326 | activeTerminalGroupId: thread.activeTerminalGroupId, |
| 254 | messages: thread.messages.map((message) => ({ | 327 | messages: thread.messages.map((message) => ({ |
| 255 | id: message.id, | 328 | id: message.id, |
| 256 | role: message.role, | 329 | role: message.role, |
apps/web/src/store.test.ts+92-14| ... | @@ -39,8 +39,13 @@ function makeThread(overrides: Partial<Thread> = {}): Thread { | ... | @@ -39,8 +39,13 @@ function makeThread(overrides: Partial<Thread> = {}): Thread { |
| 39 | terminalHeight: DEFAULT_THREAD_TERMINAL_HEIGHT, | 39 | terminalHeight: DEFAULT_THREAD_TERMINAL_HEIGHT, |
| 40 | terminalIds: [DEFAULT_THREAD_TERMINAL_ID], | 40 | terminalIds: [DEFAULT_THREAD_TERMINAL_ID], |
| 41 | activeTerminalId: DEFAULT_THREAD_TERMINAL_ID, | 41 | activeTerminalId: DEFAULT_THREAD_TERMINAL_ID, |
| 42 | terminalLayout: "single", | 42 | terminalGroups: [ |
| 43 | splitTerminalIds: [], | 43 | { |
| 44 | id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, | ||
| 45 | terminalIds: [DEFAULT_THREAD_TERMINAL_ID], | ||
| 46 | }, | ||
| 47 | ], | ||
| 48 | activeTerminalGroupId: `group-${DEFAULT_THREAD_TERMINAL_ID}`, | ||
| 44 | session: makeSession(), | 49 | session: makeSession(), |
| 45 | messages: [], | 50 | messages: [], |
| 46 | events: [], | 51 | events: [], |
| ... | @@ -123,10 +128,15 @@ describe("store reducer thread continuity", () => { | ... | @@ -123,10 +128,15 @@ describe("store reducer thread continuity", () => { |
| 123 | terminalId: "term-2", | 128 | terminalId: "term-2", |
| 124 | }); | 129 | }); |
| 125 | 130 | ||
| 126 | expect(next.threads[0]?.terminalLayout).toBe("split"); | ||
| 127 | expect(next.threads[0]?.terminalIds).toEqual([DEFAULT_THREAD_TERMINAL_ID, "term-2"]); | 131 | expect(next.threads[0]?.terminalIds).toEqual([DEFAULT_THREAD_TERMINAL_ID, "term-2"]); |
| 128 | expect(next.threads[0]?.splitTerminalIds).toEqual([DEFAULT_THREAD_TERMINAL_ID, "term-2"]); | ||
| 129 | expect(next.threads[0]?.activeTerminalId).toBe("term-2"); | 132 | expect(next.threads[0]?.activeTerminalId).toBe("term-2"); |
| 133 | expect(next.threads[0]?.terminalGroups).toEqual([ | ||
| 134 | { | ||
| 135 | id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, | ||
| 136 | terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2"], | ||
| 137 | }, | ||
| 138 | ]); | ||
| 139 | expect(next.threads[0]?.activeTerminalGroupId).toBe(`group-${DEFAULT_THREAD_TERMINAL_ID}`); | ||
| 130 | }); | 140 | }); |
| 131 | 141 | ||
| 132 | it("creates a new full-width terminal and switches to tab mode", () => { | 142 | it("creates a new full-width terminal and switches to tab mode", () => { |
| ... | @@ -137,18 +147,31 @@ describe("store reducer thread continuity", () => { | ... | @@ -137,18 +147,31 @@ describe("store reducer thread continuity", () => { |
| 137 | terminalId: "term-2", | 147 | terminalId: "term-2", |
| 138 | }); | 148 | }); |
| 139 | 149 | ||
| 140 | expect(next.threads[0]?.terminalLayout).toBe("tabs"); | ||
| 141 | expect(next.threads[0]?.terminalIds).toEqual([DEFAULT_THREAD_TERMINAL_ID, "term-2"]); | 150 | expect(next.threads[0]?.terminalIds).toEqual([DEFAULT_THREAD_TERMINAL_ID, "term-2"]); |
| 142 | expect(next.threads[0]?.activeTerminalId).toBe("term-2"); | 151 | expect(next.threads[0]?.activeTerminalId).toBe("term-2"); |
| 143 | expect(next.threads[0]?.splitTerminalIds).toEqual([]); | 152 | expect(next.threads[0]?.terminalGroups).toEqual([ |
| 153 | { | ||
| 154 | id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, | ||
| 155 | terminalIds: [DEFAULT_THREAD_TERMINAL_ID], | ||
| 156 | }, | ||
| 157 | { id: "group-term-2", terminalIds: ["term-2"] }, | ||
| 158 | ]); | ||
| 159 | expect(next.threads[0]?.activeTerminalGroupId).toBe("group-term-2"); | ||
| 144 | }); | 160 | }); |
| 145 | 161 | ||
| 146 | it("switches the active tab terminal", () => { | 162 | it("switches the active terminal and restores its owning group", () => { |
| 147 | const state = makeState( | 163 | const state = makeState( |
| 148 | makeThread({ | 164 | makeThread({ |
| 149 | terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2"], | 165 | terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2", "term-3"], |
| 150 | activeTerminalId: DEFAULT_THREAD_TERMINAL_ID, | 166 | activeTerminalId: DEFAULT_THREAD_TERMINAL_ID, |
| 151 | terminalLayout: "tabs", | 167 | terminalGroups: [ |
| 168 | { | ||
| 169 | id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, | ||
| 170 | terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2"], | ||
| 171 | }, | ||
| 172 | { id: "group-term-3", terminalIds: ["term-3"] }, | ||
| 173 | ], | ||
| 174 | activeTerminalGroupId: "group-term-3", | ||
| 152 | }), | 175 | }), |
| 153 | ); | 176 | ); |
| 154 | const next = reducer(state, { | 177 | const next = reducer(state, { |
| ... | @@ -158,14 +181,57 @@ describe("store reducer thread continuity", () => { | ... | @@ -158,14 +181,57 @@ describe("store reducer thread continuity", () => { |
| 158 | }); | 181 | }); |
| 159 | 182 | ||
| 160 | expect(next.threads[0]?.activeTerminalId).toBe("term-2"); | 183 | expect(next.threads[0]?.activeTerminalId).toBe("term-2"); |
| 184 | expect(next.threads[0]?.activeTerminalGroupId).toBe(`group-${DEFAULT_THREAD_TERMINAL_ID}`); | ||
| 161 | }); | 185 | }); |
| 162 | 186 | ||
| 163 | it("closes a terminal tab and falls back to single layout when one remains", () => { | 187 | it("supports splitting beyond two terminals in the same group", () => { |
| 164 | const state = makeState( | 188 | const state = makeState( |
| 165 | makeThread({ | 189 | makeThread({ |
| 166 | terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2"], | 190 | terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2"], |
| 167 | activeTerminalId: "term-2", | 191 | activeTerminalId: "term-2", |
| 168 | terminalLayout: "tabs", | 192 | terminalGroups: [ |
| 193 | { | ||
| 194 | id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, | ||
| 195 | terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2"], | ||
| 196 | }, | ||
| 197 | ], | ||
| 198 | activeTerminalGroupId: `group-${DEFAULT_THREAD_TERMINAL_ID}`, | ||
| 199 | }), | ||
| 200 | ); | ||
| 201 | const next = reducer(state, { | ||
| 202 | type: "SPLIT_THREAD_TERMINAL", | ||
| 203 | threadId: "thread-local-1", | ||
| 204 | terminalId: "term-3", | ||
| 205 | }); | ||
| 206 | |||
| 207 | expect(next.threads[0]?.terminalIds).toEqual([ | ||
| 208 | DEFAULT_THREAD_TERMINAL_ID, | ||
| 209 | "term-2", | ||
| 210 | "term-3", | ||
| 211 | ]); | ||
| 212 | expect(next.threads[0]?.terminalGroups).toEqual([ | ||
| 213 | { | ||
| 214 | id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, | ||
| 215 | terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2", "term-3"], | ||
| 216 | }, | ||
| 217 | ]); | ||
| 218 | expect(next.threads[0]?.activeTerminalId).toBe("term-3"); | ||
| 219 | expect(next.threads[0]?.activeTerminalGroupId).toBe(`group-${DEFAULT_THREAD_TERMINAL_ID}`); | ||
| 220 | }); | ||
| 221 | |||
| 222 | it("closes a terminal and keeps grouped layout coherent", () => { | ||
| 223 | const state = makeState( | ||
| 224 | makeThread({ | ||
| 225 | terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2", "term-3"], | ||
| 226 | activeTerminalId: "term-2", | ||
| 227 | terminalGroups: [ | ||
| 228 | { | ||
| 229 | id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, | ||
| 230 | terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2"], | ||
| 231 | }, | ||
| 232 | { id: "group-term-3", terminalIds: ["term-3"] }, | ||
| 233 | ], | ||
| 234 | activeTerminalGroupId: `group-${DEFAULT_THREAD_TERMINAL_ID}`, | ||
| 169 | }), | 235 | }), |
| 170 | ); | 236 | ); |
| 171 | const next = reducer(state, { | 237 | const next = reducer(state, { |
| ... | @@ -174,9 +240,15 @@ describe("store reducer thread continuity", () => { | ... | @@ -174,9 +240,15 @@ describe("store reducer thread continuity", () => { |
| 174 | terminalId: "term-2", | 240 | terminalId: "term-2", |
| 175 | }); | 241 | }); |
| 176 | 242 | ||
| 177 | expect(next.threads[0]?.terminalIds).toEqual([DEFAULT_THREAD_TERMINAL_ID]); | 243 | expect(next.threads[0]?.terminalIds).toEqual([DEFAULT_THREAD_TERMINAL_ID, "term-3"]); |
| 178 | expect(next.threads[0]?.activeTerminalId).toBe(DEFAULT_THREAD_TERMINAL_ID); | 244 | expect(next.threads[0]?.activeTerminalId).toBe(DEFAULT_THREAD_TERMINAL_ID); |
| 179 | expect(next.threads[0]?.terminalLayout).toBe("single"); | 245 | expect(next.threads[0]?.terminalGroups).toEqual([ |
| 246 | { | ||
| 247 | id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, | ||
| 248 | terminalIds: [DEFAULT_THREAD_TERMINAL_ID], | ||
| 249 | }, | ||
| 250 | { id: "group-term-3", terminalIds: ["term-3"] }, | ||
| 251 | ]); | ||
| 180 | }); | 252 | }); |
| 181 | 253 | ||
| 182 | it("closes the final terminal and hides the drawer", () => { | 254 | it("closes the final terminal and hides the drawer", () => { |
| ... | @@ -193,7 +265,13 @@ describe("store reducer thread continuity", () => { | ... | @@ -193,7 +265,13 @@ describe("store reducer thread continuity", () => { |
| 193 | 265 | ||
| 194 | expect(next.threads[0]?.terminalOpen).toBe(false); | 266 | expect(next.threads[0]?.terminalOpen).toBe(false); |
| 195 | expect(next.threads[0]?.terminalIds).toEqual([DEFAULT_THREAD_TERMINAL_ID]); | 267 | expect(next.threads[0]?.terminalIds).toEqual([DEFAULT_THREAD_TERMINAL_ID]); |
| 196 | expect(next.threads[0]?.terminalLayout).toBe("single"); | 268 | expect(next.threads[0]?.activeTerminalId).toBe(DEFAULT_THREAD_TERMINAL_ID); |
| 269 | expect(next.threads[0]?.terminalGroups).toEqual([ | ||
| 270 | { | ||
| 271 | id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, | ||
| 272 | terminalIds: [DEFAULT_THREAD_TERMINAL_ID], | ||
| 273 | }, | ||
| 274 | ]); | ||
| 197 | }); | 275 | }); |
| 198 | 276 | ||
| 199 | it("backfills codexThreadId from routed provider events", () => { | 277 | it("backfills codexThreadId from routed provider events", () => { |
apps/web/src/store.ts+217-75| ... | @@ -19,6 +19,7 @@ import { | ... | @@ -19,6 +19,7 @@ import { |
| 19 | type Project, | 19 | type Project, |
| 20 | type RuntimeMode, | 20 | type RuntimeMode, |
| 21 | type Thread, | 21 | type Thread, |
| 22 | type ThreadTerminalGroup, | ||
| 22 | } from "./types"; | 23 | } from "./types"; |
| 23 | 24 | ||
| 24 | // ── Actions ────────────────────────────────────────────────────────── | 25 | // ── Actions ────────────────────────────────────────────────────────── |
| ... | @@ -72,8 +73,9 @@ export interface AppState { | ... | @@ -72,8 +73,9 @@ export interface AppState { |
| 72 | diffOpen: boolean; | 73 | diffOpen: boolean; |
| 73 | } | 74 | } |
| 74 | 75 | ||
| 75 | const PERSISTED_STATE_KEY = "t3code:renderer-state:v6"; | 76 | const PERSISTED_STATE_KEY = "t3code:renderer-state:v7"; |
| 76 | const LEGACY_PERSISTED_STATE_KEYS = [ | 77 | const LEGACY_PERSISTED_STATE_KEYS = [ |
| 78 | "t3code:renderer-state:v6", | ||
| 77 | "t3code:renderer-state:v5", | 79 | "t3code:renderer-state:v5", |
| 78 | "t3code:renderer-state:v4", | 80 | "t3code:renderer-state:v4", |
| 79 | "t3code:renderer-state:v3", | 81 | "t3code:renderer-state:v3", |
| ... | @@ -139,9 +141,7 @@ function updateThread( | ... | @@ -139,9 +141,7 @@ function updateThread( |
| 139 | } | 141 | } |
| 140 | 142 | ||
| 141 | function normalizeTerminalIds(terminalIds: string[]): string[] { | 143 | function normalizeTerminalIds(terminalIds: string[]): string[] { |
| 142 | const ids = terminalIds | 144 | const ids = terminalIds.map((id) => id.trim()).filter((id) => id.length > 0); |
| 143 | .map((id) => id.trim()) | ||
| 144 | .filter((id) => id.length > 0); | ||
| 145 | const unique = [...new Set(ids)]; | 145 | const unique = [...new Set(ids)]; |
| 146 | if (unique.length > 0) { | 146 | if (unique.length > 0) { |
| 147 | return unique; | 147 | return unique; |
| ... | @@ -149,44 +149,112 @@ function normalizeTerminalIds(terminalIds: string[]): string[] { | ... | @@ -149,44 +149,112 @@ function normalizeTerminalIds(terminalIds: string[]): string[] { |
| 149 | return [DEFAULT_THREAD_TERMINAL_ID]; | 149 | return [DEFAULT_THREAD_TERMINAL_ID]; |
| 150 | } | 150 | } |
| 151 | 151 | ||
| 152 | function normalizeTerminalGroupIds(terminalIds: string[]): string[] { | ||
| 153 | return [...new Set(terminalIds.map((id) => id.trim()).filter((id) => id.length > 0))]; | ||
| 154 | } | ||
| 155 | |||
| 156 | function fallbackGroupId(terminalId: string): string { | ||
| 157 | return `group-${terminalId}`; | ||
| 158 | } | ||
| 159 | |||
| 160 | function assignUniqueGroupId(groupId: string, usedGroupIds: Set<string>): string { | ||
| 161 | if (!usedGroupIds.has(groupId)) { | ||
| 162 | usedGroupIds.add(groupId); | ||
| 163 | return groupId; | ||
| 164 | } | ||
| 165 | let suffix = 2; | ||
| 166 | while (usedGroupIds.has(`${groupId}-${suffix}`)) { | ||
| 167 | suffix += 1; | ||
| 168 | } | ||
| 169 | const uniqueGroupId = `${groupId}-${suffix}`; | ||
| 170 | usedGroupIds.add(uniqueGroupId); | ||
| 171 | return uniqueGroupId; | ||
| 172 | } | ||
| 173 | |||
| 174 | function normalizeTerminalGroups( | ||
| 175 | thread: Thread, | ||
| 176 | terminalIds: string[], | ||
| 177 | ): ThreadTerminalGroup[] { | ||
| 178 | const validTerminalIdSet = new Set(terminalIds); | ||
| 179 | const assignedTerminalIds = new Set<string>(); | ||
| 180 | const usedGroupIds = new Set<string>(); | ||
| 181 | const groups: ThreadTerminalGroup[] = []; | ||
| 182 | |||
| 183 | for (const group of thread.terminalGroups) { | ||
| 184 | const groupTerminalIds = normalizeTerminalGroupIds(group.terminalIds).filter((terminalId) => { | ||
| 185 | if (!validTerminalIdSet.has(terminalId)) return false; | ||
| 186 | if (assignedTerminalIds.has(terminalId)) return false; | ||
| 187 | return true; | ||
| 188 | }); | ||
| 189 | if (groupTerminalIds.length === 0) continue; | ||
| 190 | for (const terminalId of groupTerminalIds) { | ||
| 191 | assignedTerminalIds.add(terminalId); | ||
| 192 | } | ||
| 193 | const baseGroupId = | ||
| 194 | group.id.trim().length > 0 | ||
| 195 | ? group.id.trim() | ||
| 196 | : fallbackGroupId(groupTerminalIds[0] ?? DEFAULT_THREAD_TERMINAL_ID); | ||
| 197 | groups.push({ | ||
| 198 | id: assignUniqueGroupId(baseGroupId, usedGroupIds), | ||
| 199 | terminalIds: groupTerminalIds, | ||
| 200 | }); | ||
| 201 | } | ||
| 202 | |||
| 203 | for (const terminalId of terminalIds) { | ||
| 204 | if (assignedTerminalIds.has(terminalId)) continue; | ||
| 205 | groups.push({ | ||
| 206 | id: assignUniqueGroupId(fallbackGroupId(terminalId), usedGroupIds), | ||
| 207 | terminalIds: [terminalId], | ||
| 208 | }); | ||
| 209 | } | ||
| 210 | |||
| 211 | if (groups.length > 0) { | ||
| 212 | return groups; | ||
| 213 | } | ||
| 214 | |||
| 215 | return [ | ||
| 216 | { | ||
| 217 | id: fallbackGroupId(DEFAULT_THREAD_TERMINAL_ID), | ||
| 218 | terminalIds: [DEFAULT_THREAD_TERMINAL_ID], | ||
| 219 | }, | ||
| 220 | ]; | ||
| 221 | } | ||
| 222 | |||
| 223 | function findGroupIndexByTerminalId( | ||
| 224 | terminalGroups: ThreadTerminalGroup[], | ||
| 225 | terminalId: string, | ||
| 226 | ): number { | ||
| 227 | return terminalGroups.findIndex((group) => group.terminalIds.includes(terminalId)); | ||
| 228 | } | ||
| 229 | |||
| 152 | function normalizeThreadTerminals(thread: Thread): Thread { | 230 | function normalizeThreadTerminals(thread: Thread): Thread { |
| 153 | const terminalIds = normalizeTerminalIds(thread.terminalIds); | 231 | const terminalIds = normalizeTerminalIds(thread.terminalIds); |
| 154 | const activeTerminalId = terminalIds.includes(thread.activeTerminalId) | 232 | const activeTerminalId = terminalIds.includes(thread.activeTerminalId) |
| 155 | ? thread.activeTerminalId | 233 | ? thread.activeTerminalId |
| 156 | : (terminalIds[0] ?? DEFAULT_THREAD_TERMINAL_ID); | 234 | : (terminalIds[0] ?? DEFAULT_THREAD_TERMINAL_ID); |
| 157 | 235 | const terminalGroups = normalizeTerminalGroups(thread, terminalIds); | |
| 158 | if (thread.terminalLayout === "split") { | 236 | const activeGroupIndexFromId = terminalGroups.findIndex( |
| 159 | const validSplitIds = [...new Set(thread.splitTerminalIds)].filter((id) => | 237 | (group) => group.id === thread.activeTerminalGroupId, |
| 160 | terminalIds.includes(id), | 238 | ); |
| 161 | ); | 239 | const activeGroupIndexFromTerminal = findGroupIndexByTerminalId( |
| 162 | if (validSplitIds.length >= 2) { | 240 | terminalGroups, |
| 163 | const splitTerminalIds = validSplitIds.slice(0, 2); | 241 | activeTerminalId, |
| 164 | const splitActiveTerminalId = splitTerminalIds.includes(activeTerminalId) | 242 | ); |
| 165 | ? activeTerminalId | 243 | const activeGroupIndex = |
| 166 | : (splitTerminalIds[1] ?? splitTerminalIds[0] ?? activeTerminalId); | 244 | activeGroupIndexFromId >= 0 |
| 167 | return { | 245 | ? activeGroupIndexFromId |
| 168 | ...thread, | 246 | : (activeGroupIndexFromTerminal >= 0 ? activeGroupIndexFromTerminal : 0); |
| 169 | terminalIds, | 247 | const activeTerminalGroupId = |
| 170 | activeTerminalId: splitActiveTerminalId, | 248 | terminalGroups[activeGroupIndex]?.id ?? |
| 171 | terminalLayout: "split", | 249 | terminalGroups[0]?.id ?? |
| 172 | splitTerminalIds, | 250 | fallbackGroupId(activeTerminalId); |
| 173 | }; | ||
| 174 | } | ||
| 175 | } | ||
| 176 | |||
| 177 | const terminalLayout = | ||
| 178 | terminalIds.length > 1 && thread.terminalLayout === "single" | ||
| 179 | ? "tabs" | ||
| 180 | : (thread.terminalLayout === "tabs" && terminalIds.length < 2 | ||
| 181 | ? "single" | ||
| 182 | : thread.terminalLayout); | ||
| 183 | 251 | ||
| 184 | return { | 252 | return { |
| 185 | ...thread, | 253 | ...thread, |
| 186 | terminalIds, | 254 | terminalIds, |
| 187 | activeTerminalId, | 255 | activeTerminalId, |
| 188 | terminalLayout, | 256 | terminalGroups, |
| 189 | splitTerminalIds: [], | 257 | activeTerminalGroupId, |
| 190 | }; | 258 | }; |
| 191 | } | 259 | } |
| 192 | 260 | ||
| ... | @@ -202,33 +270,47 @@ function closeThreadTerminal(thread: Thread, terminalId: string): Thread { | ... | @@ -202,33 +270,47 @@ function closeThreadTerminal(thread: Thread, terminalId: string): Thread { |
| 202 | terminalOpen: false, | 270 | terminalOpen: false, |
| 203 | terminalIds: [DEFAULT_THREAD_TERMINAL_ID], | 271 | terminalIds: [DEFAULT_THREAD_TERMINAL_ID], |
| 204 | activeTerminalId: DEFAULT_THREAD_TERMINAL_ID, | 272 | activeTerminalId: DEFAULT_THREAD_TERMINAL_ID, |
| 205 | terminalLayout: "single", | 273 | terminalGroups: [ |
| 206 | splitTerminalIds: [], | 274 | { |
| 275 | id: fallbackGroupId(DEFAULT_THREAD_TERMINAL_ID), | ||
| 276 | terminalIds: [DEFAULT_THREAD_TERMINAL_ID], | ||
| 277 | }, | ||
| 278 | ], | ||
| 279 | activeTerminalGroupId: fallbackGroupId(DEFAULT_THREAD_TERMINAL_ID), | ||
| 207 | }); | 280 | }); |
| 208 | } | 281 | } |
| 209 | 282 | ||
| 283 | const closedTerminalIndex = thread.terminalIds.indexOf(terminalId); | ||
| 284 | const closedTerminalGroup = thread.terminalGroups.find((group) => | ||
| 285 | group.terminalIds.includes(terminalId), | ||
| 286 | ); | ||
| 287 | const closedTerminalGroupIndex = closedTerminalGroup | ||
| 288 | ? closedTerminalGroup.terminalIds.indexOf(terminalId) | ||
| 289 | : -1; | ||
| 290 | const remainingTerminalsInClosedGroup = ( | ||
| 291 | closedTerminalGroup?.terminalIds ?? [] | ||
| 292 | ).filter((id) => id !== terminalId); | ||
| 210 | const nextActiveTerminalId = | 293 | const nextActiveTerminalId = |
| 211 | thread.activeTerminalId === terminalId | 294 | thread.activeTerminalId === terminalId |
| 212 | ? (remainingTerminalIds[0] ?? DEFAULT_THREAD_TERMINAL_ID) | 295 | ? (remainingTerminalsInClosedGroup[ |
| 296 | Math.min(closedTerminalGroupIndex, remainingTerminalsInClosedGroup.length - 1) | ||
| 297 | ] ?? | ||
| 298 | remainingTerminalIds[Math.min(closedTerminalIndex, remainingTerminalIds.length - 1)] ?? | ||
| 299 | remainingTerminalIds[0] ?? | ||
| 300 | DEFAULT_THREAD_TERMINAL_ID) | ||
| 213 | : thread.activeTerminalId; | 301 | : thread.activeTerminalId; |
| 214 | let nextLayout = thread.terminalLayout; | 302 | const nextTerminalGroups = thread.terminalGroups |
| 215 | let nextSplitTerminalIds = thread.splitTerminalIds.filter((id) => id !== terminalId); | 303 | .map((group) => ({ |
| 216 | 304 | ...group, | |
| 217 | if (nextLayout === "split" && nextSplitTerminalIds.length < 2) { | 305 | terminalIds: group.terminalIds.filter((id) => id !== terminalId), |
| 218 | nextLayout = remainingTerminalIds.length > 1 ? "tabs" : "single"; | 306 | })) |
| 219 | nextSplitTerminalIds = []; | 307 | .filter((group) => group.terminalIds.length > 0); |
| 220 | } | ||
| 221 | |||
| 222 | if (nextLayout === "tabs" && remainingTerminalIds.length < 2) { | ||
| 223 | nextLayout = "single"; | ||
| 224 | } | ||
| 225 | 308 | ||
| 226 | return normalizeThreadTerminals({ | 309 | return normalizeThreadTerminals({ |
| 227 | ...thread, | 310 | ...thread, |
| 228 | terminalIds: remainingTerminalIds, | 311 | terminalIds: remainingTerminalIds, |
| 229 | activeTerminalId: nextActiveTerminalId, | 312 | activeTerminalId: nextActiveTerminalId, |
| 230 | terminalLayout: nextLayout, | 313 | terminalGroups: nextTerminalGroups, |
| 231 | splitTerminalIds: nextSplitTerminalIds, | ||
| 232 | }); | 314 | }); |
| 233 | } | 315 | } |
| 234 | 316 | ||
| ... | @@ -440,28 +522,67 @@ export function reducer(state: AppState, action: Action): AppState { | ... | @@ -440,28 +522,67 @@ export function reducer(state: AppState, action: Action): AppState { |
| 440 | return { | 522 | return { |
| 441 | ...state, | 523 | ...state, |
| 442 | threads: updateThread(state.threads, action.threadId, (thread) => { | 524 | threads: updateThread(state.threads, action.threadId, (thread) => { |
| 443 | const terminalIds = thread.terminalIds.includes(action.terminalId) | 525 | const normalizedThread = normalizeThreadTerminals(thread); |
| 444 | ? thread.terminalIds | 526 | const terminalIds = normalizedThread.terminalIds.includes(action.terminalId) |
| 445 | : [...thread.terminalIds, action.terminalId]; | 527 | ? normalizedThread.terminalIds |
| 446 | const firstTerminalId = | 528 | : [...normalizedThread.terminalIds, action.terminalId]; |
| 447 | terminalIds.includes(thread.activeTerminalId) | 529 | const terminalGroups = normalizedThread.terminalGroups.map((group) => ({ |
| 448 | ? thread.activeTerminalId | 530 | ...group, |
| 449 | : (terminalIds[0] ?? DEFAULT_THREAD_TERMINAL_ID); | 531 | terminalIds: [...group.terminalIds], |
| 450 | const secondTerminalId = | 532 | })); |
| 451 | action.terminalId === firstTerminalId | 533 | let activeGroupIndex = terminalGroups.findIndex( |
| 452 | ? (terminalIds.find((id) => id !== firstTerminalId) ?? firstTerminalId) | 534 | (group) => group.id === normalizedThread.activeTerminalGroupId, |
| 453 | : action.terminalId; | 535 | ); |
| 454 | const splitTerminalIds = | 536 | if (activeGroupIndex < 0) { |
| 455 | firstTerminalId === secondTerminalId | 537 | activeGroupIndex = findGroupIndexByTerminalId( |
| 456 | ? [] | 538 | terminalGroups, |
| 457 | : [firstTerminalId, secondTerminalId]; | 539 | normalizedThread.activeTerminalId, |
| 540 | ); | ||
| 541 | } | ||
| 542 | if (activeGroupIndex < 0) { | ||
| 543 | terminalGroups.push({ | ||
| 544 | id: fallbackGroupId(normalizedThread.activeTerminalId), | ||
| 545 | terminalIds: [normalizedThread.activeTerminalId], | ||
| 546 | }); | ||
| 547 | activeGroupIndex = terminalGroups.length - 1; | ||
| 548 | } | ||
| 458 | 549 | ||
| 550 | const existingGroupIndex = findGroupIndexByTerminalId( | ||
| 551 | terminalGroups, | ||
| 552 | action.terminalId, | ||
| 553 | ); | ||
| 554 | if (existingGroupIndex >= 0) { | ||
| 555 | terminalGroups[existingGroupIndex]!.terminalIds = terminalGroups[ | ||
| 556 | existingGroupIndex | ||
| 557 | ]!.terminalIds.filter((id) => id !== action.terminalId); | ||
| 558 | if (terminalGroups[existingGroupIndex]!.terminalIds.length === 0) { | ||
| 559 | terminalGroups.splice(existingGroupIndex, 1); | ||
| 560 | if (existingGroupIndex < activeGroupIndex) { | ||
| 561 | activeGroupIndex -= 1; | ||
| 562 | } | ||
| 563 | } | ||
| 564 | } | ||
| 565 | |||
| 566 | const destinationGroup = terminalGroups[activeGroupIndex]; | ||
| 567 | if (!destinationGroup) { | ||
| 568 | return normalizedThread; | ||
| 569 | } | ||
| 570 | if (!destinationGroup.terminalIds.includes(action.terminalId)) { | ||
| 571 | const anchorIndex = destinationGroup.terminalIds.indexOf( | ||
| 572 | normalizedThread.activeTerminalId, | ||
| 573 | ); | ||
| 574 | if (anchorIndex >= 0) { | ||
| 575 | destinationGroup.terminalIds.splice(anchorIndex + 1, 0, action.terminalId); | ||
| 576 | } else { | ||
| 577 | destinationGroup.terminalIds.push(action.terminalId); | ||
| 578 | } | ||
| 579 | } | ||
| 459 | return normalizeThreadTerminals({ | 580 | return normalizeThreadTerminals({ |
| 460 | ...thread, | 581 | ...normalizedThread, |
| 461 | terminalIds, | 582 | terminalIds, |
| 462 | activeTerminalId: secondTerminalId, | 583 | activeTerminalId: action.terminalId, |
| 463 | terminalLayout: splitTerminalIds.length === 2 ? "split" : "single", | 584 | activeTerminalGroupId: destinationGroup.id, |
| 464 | splitTerminalIds, | 585 | terminalGroups, |
| 465 | }); | 586 | }); |
| 466 | }), | 587 | }), |
| 467 | }; | 588 | }; |
| ... | @@ -470,15 +591,25 @@ export function reducer(state: AppState, action: Action): AppState { | ... | @@ -470,15 +591,25 @@ export function reducer(state: AppState, action: Action): AppState { |
| 470 | return { | 591 | return { |
| 471 | ...state, | 592 | ...state, |
| 472 | threads: updateThread(state.threads, action.threadId, (thread) => { | 593 | threads: updateThread(state.threads, action.threadId, (thread) => { |
| 473 | const terminalIds = thread.terminalIds.includes(action.terminalId) | 594 | const normalizedThread = normalizeThreadTerminals(thread); |
| 474 | ? thread.terminalIds | 595 | const terminalIds = normalizedThread.terminalIds.includes(action.terminalId) |
| 475 | : [...thread.terminalIds, action.terminalId]; | 596 | ? normalizedThread.terminalIds |
| 597 | : [...normalizedThread.terminalIds, action.terminalId]; | ||
| 598 | const terminalGroups = normalizedThread.terminalGroups | ||
| 599 | .map((group) => ({ | ||
| 600 | ...group, | ||
| 601 | terminalIds: group.terminalIds.filter((id) => id !== action.terminalId), | ||
| 602 | })) | ||
| 603 | .filter((group) => group.terminalIds.length > 0); | ||
| 604 | const nextGroupId = fallbackGroupId(action.terminalId); | ||
| 605 | terminalGroups.push({ id: nextGroupId, terminalIds: [action.terminalId] }); | ||
| 606 | |||
| 476 | return normalizeThreadTerminals({ | 607 | return normalizeThreadTerminals({ |
| 477 | ...thread, | 608 | ...normalizedThread, |
| 478 | terminalIds, | 609 | terminalIds, |
| 479 | activeTerminalId: action.terminalId, | 610 | activeTerminalId: action.terminalId, |
| 480 | terminalLayout: "tabs", | 611 | activeTerminalGroupId: nextGroupId, |
| 481 | splitTerminalIds: [], | 612 | terminalGroups, |
| 482 | }); | 613 | }); |
| 483 | }), | 614 | }), |
| 484 | }; | 615 | }; |
| ... | @@ -487,12 +618,23 @@ export function reducer(state: AppState, action: Action): AppState { | ... | @@ -487,12 +618,23 @@ export function reducer(state: AppState, action: Action): AppState { |
| 487 | return { | 618 | return { |
| 488 | ...state, | 619 | ...state, |
| 489 | threads: updateThread(state.threads, action.threadId, (thread) => { | 620 | threads: updateThread(state.threads, action.threadId, (thread) => { |
| 490 | if (!thread.terminalIds.includes(action.terminalId)) { | 621 | const normalizedThread = normalizeThreadTerminals(thread); |
| 622 | if (!normalizedThread.terminalIds.includes(action.terminalId)) { | ||
| 491 | return thread; | 623 | return thread; |
| 492 | } | 624 | } |
| 625 | const nextActiveGroupIndex = findGroupIndexByTerminalId( | ||
| 626 | normalizedThread.terminalGroups, | ||
| 627 | action.terminalId, | ||
| 628 | ); | ||
| 629 | const activeTerminalGroupId = | ||
| 630 | nextActiveGroupIndex >= 0 | ||
| 631 | ? (normalizedThread.terminalGroups[nextActiveGroupIndex]?.id ?? | ||
| 632 | normalizedThread.activeTerminalGroupId) | ||
| 633 | : normalizedThread.activeTerminalGroupId; | ||
| 493 | return normalizeThreadTerminals({ | 634 | return normalizeThreadTerminals({ |
| 494 | ...thread, | 635 | ...normalizedThread, |
| 495 | activeTerminalId: action.terminalId, | 636 | activeTerminalId: action.terminalId, |
| 637 | activeTerminalGroupId, | ||
| 496 | }); | 638 | }); |
| 497 | }), | 639 | }), |
| 498 | }; | 640 | }; |
apps/web/src/types.ts+7-3| ... | @@ -5,7 +5,11 @@ export type RuntimeMode = "approval-required" | "full-access"; | ... | @@ -5,7 +5,11 @@ export type RuntimeMode = "approval-required" | "full-access"; |
| 5 | export const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access"; | 5 | export const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access"; |
| 6 | export const DEFAULT_THREAD_TERMINAL_HEIGHT = 280; | 6 | export const DEFAULT_THREAD_TERMINAL_HEIGHT = 280; |
| 7 | export const DEFAULT_THREAD_TERMINAL_ID = "default"; | 7 | export const DEFAULT_THREAD_TERMINAL_ID = "default"; |
| 8 | export type ThreadTerminalLayout = "single" | "split" | "tabs"; | 8 | |
| 9 | export interface ThreadTerminalGroup { | ||
| 10 | id: string; | ||
| 11 | terminalIds: string[]; | ||
| 12 | } | ||
| 9 | 13 | ||
| 10 | export interface ChatImageAttachment { | 14 | export interface ChatImageAttachment { |
| 11 | type: "image"; | 15 | type: "image"; |
| ... | @@ -45,8 +49,8 @@ export interface Thread { | ... | @@ -45,8 +49,8 @@ export interface Thread { |
| 45 | terminalHeight: number; | 49 | terminalHeight: number; |
| 46 | terminalIds: string[]; | 50 | terminalIds: string[]; |
| 47 | activeTerminalId: string; | 51 | activeTerminalId: string; |
| 48 | terminalLayout: ThreadTerminalLayout; | 52 | terminalGroups: ThreadTerminalGroup[]; |
| 49 | splitTerminalIds: string[]; | 53 | activeTerminalGroupId: string; |
| 50 | session: ProviderSession | null; | 54 | session: ProviderSession | null; |
| 51 | messages: ChatMessage[]; | 55 | messages: ChatMessage[]; |
| 52 | events: ProviderEvent[]; | 56 | events: ProviderEvent[]; |