- 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 tests
Unified · Split
| @@ -83,8 +83,13 @@ function AutoProjectBootstrap() { · 82 unmodified lines | ||
| 83 | 83 | terminalHeight: DEFAULT_THREAD_TERMINAL_HEIGHT, |
| 84 | 84 | terminalIds: [DEFAULT_THREAD_TERMINAL_ID], |
| 85 | 85 | activeTerminalId: DEFAULT_THREAD_TERMINAL_ID, |
| 86 | − terminalLayout: "single", | |
| 87 | − splitTerminalIds: [], | |
| 86 | + terminalGroups: [ | |
| 87 | + { | |
| 88 | + id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, | |
| 89 | + terminalIds: [DEFAULT_THREAD_TERMINAL_ID], | |
| 90 | + }, | |
| 91 | + ], | |
| 92 | + activeTerminalGroupId: `group-${DEFAULT_THREAD_TERMINAL_ID}`, | |
| 88 | 93 | session: null, |
| 89 | 94 | messages: [], |
| 90 | 95 | events: [], |
| @@ -1498,8 +1498,8 @@ export default function ChatView() { · 1497 unmodified lines | ||
| 1498 | 1498 | height={activeThread.terminalHeight} |
| 1499 | 1499 | terminalIds={activeThread.terminalIds} |
| 1500 | 1500 | activeTerminalId={activeThread.activeTerminalId} |
| 1501 | − terminalLayout={activeThread.terminalLayout} | |
| 1502 | − splitTerminalIds={activeThread.splitTerminalIds} | |
| 1501 | + terminalGroups={activeThread.terminalGroups} | |
| 1502 | + activeTerminalGroupId={activeThread.activeTerminalGroupId} | |
| 1503 | 1503 | focusRequestId={terminalFocusRequestId} |
| 1504 | 1504 | onSplitTerminal={splitTerminal} |
| 1505 | 1505 | onNewTerminal={createNewTerminal} |
| @@ -119,8 +119,13 @@ export default function Sidebar() { · 118 unmodified lines | ||
| 119 | 119 | terminalHeight: DEFAULT_THREAD_TERMINAL_HEIGHT, |
| 120 | 120 | terminalIds: [DEFAULT_THREAD_TERMINAL_ID], |
| 121 | 121 | activeTerminalId: DEFAULT_THREAD_TERMINAL_ID, |
| 122 | − terminalLayout: "single", | |
| 123 | − splitTerminalIds: [], | |
| 122 | + terminalGroups: [ | |
| 123 | + { | |
| 124 | + id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, | |
| 125 | + terminalIds: [DEFAULT_THREAD_TERMINAL_ID], | |
| 126 | + }, | |
| 127 | + ], | |
| 128 | + activeTerminalGroupId: `group-${DEFAULT_THREAD_TERMINAL_ID}`, | |
| 124 | 129 | session: null, |
| 125 | 130 | messages: [], |
| 126 | 131 | events: [], |
| @@ -1,4 +1,5 @@ | ||
| 1 | 1 | import { FitAddon } from "@xterm/addon-fit"; |
| 2 | +import { Plus, SquareSplitHorizontal, TerminalSquare, Trash2 } from "lucide-react"; | |
| 2 | 3 | import { type NativeApi } from "@t3tools/contracts"; |
| 3 | 4 | import { Terminal, type ITheme } from "@xterm/xterm"; |
| 4 | 5 | import { |
| @@ -19,7 +20,7 @@ import { isTerminalClearShortcut } from "../terminal-shortcuts"; · 14 unmodified lines | ||
| 19 | 20 | import { |
| 20 | 21 | DEFAULT_THREAD_TERMINAL_HEIGHT, |
| 21 | 22 | DEFAULT_THREAD_TERMINAL_ID, |
| 22 | − type ThreadTerminalLayout, | |
| 23 | + type ThreadTerminalGroup, | |
| 23 | 24 | } from "../types"; |
| 24 | 25 | |
| 25 | 26 | const MIN_DRAWER_HEIGHT = 180; |
| @@ -415,8 +416,8 @@ interface ThreadTerminalDrawerProps { · 389 unmodified lines | ||
| 415 | 416 | height: number; |
| 416 | 417 | terminalIds: string[]; |
| 417 | 418 | activeTerminalId: string; |
| 418 | − terminalLayout: ThreadTerminalLayout; | |
| 419 | − splitTerminalIds: string[]; | |
| 419 | + terminalGroups: ThreadTerminalGroup[]; | |
| 420 | + activeTerminalGroupId: string; | |
| 420 | 421 | focusRequestId: number; |
| 421 | 422 | onSplitTerminal: () => void; |
| 422 | 423 | onNewTerminal: () => void; |
| @@ -432,8 +433,8 @@ export default function ThreadTerminalDrawer({ · 9 unmodified lines | ||
| 432 | 433 | height, |
| 433 | 434 | terminalIds, |
| 434 | 435 | activeTerminalId, |
| 435 | − terminalLayout, | |
| 436 | − splitTerminalIds, | |
| 436 | + terminalGroups, | |
| 437 | + activeTerminalGroupId, | |
| 437 | 438 | focusRequestId, |
| 438 | 439 | onSplitTerminal, |
| 439 | 440 | onNewTerminal, |
| @@ -462,28 +463,88 @@ export default function ThreadTerminalDrawer({ · 22 unmodified lines | ||
| 462 | 463 | ? activeTerminalId |
| 463 | 464 | : (normalizedTerminalIds[0] ?? DEFAULT_THREAD_TERMINAL_ID); |
| 464 | 465 | |
| 465 | − const resolvedSplitTerminalIds = useMemo(() => { | |
| 466 | − if (terminalLayout !== "split") return []; | |
| 467 | − const validSplitIds = [...new Set(splitTerminalIds)].filter((id) => | |
| 468 | − normalizedTerminalIds.includes(id), | |
| 469 | − ); | |
| 470 | − if (validSplitIds.length >= 2) { | |
| 471 | − return validSplitIds.slice(0, 2); | |
| 466 | + const resolvedTerminalGroups = useMemo(() => { | |
| 467 | + const validTerminalIdSet = new Set(normalizedTerminalIds); | |
| 468 | + const assignedTerminalIds = new Set<string>(); | |
| 469 | + const usedGroupIds = new Set<string>(); | |
| 470 | + const nextGroups: ThreadTerminalGroup[] = []; | |
| 471 | + | |
| 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); | |
| 475 | − if (!fallbackPartner) { | |
| 476 | − return []; | |
| 510 | + for (const terminalId of normalizedTerminalIds) { | |
| 511 | + if (assignedTerminalIds.has(terminalId)) continue; | |
| 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 = | |
| 482 | − terminalLayout === "split" && resolvedSplitTerminalIds.length === 2 | |
| 483 | − ? resolvedSplitTerminalIds | |
| 484 | − : [resolvedActiveTerminalId]; | |
| 518 | + if (nextGroups.length > 0) { | |
| 519 | + return nextGroups; | |
| 520 | + } | |
| 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 | 548 | const terminalLabelById = useMemo( |
| 488 | 549 | () => |
| 489 | 550 | new Map( |
| @@ -585,147 +646,218 @@ export default function ThreadTerminalDrawer({ · 95 unmodified lines | ||
| 585 | 646 | |
| 586 | 647 | return ( |
| 587 | 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 | 650 | style={{ height: `${drawerHeight}px` }} |
| 590 | 651 | > |
| 591 | 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 | 654 | onPointerDown={handleResizePointerDown} |
| 594 | 655 | onPointerMove={handleResizePointerMove} |
| 595 | 656 | onPointerUp={handleResizePointerEnd} |
| 596 | 657 | onPointerCancel={handleResizePointerEnd} |
| 597 | − > | |
| 598 | − <div className="h-px w-10 rounded-full bg-border/80" /> | |
| 599 | − </div> | |
| 658 | + /> | |
| 659 | + | |
| 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"> | |
| 602 | − {showTabBar ? ( | |
| 603 | − <div className="min-w-0 flex-1 overflow-x-auto"> | |
| 604 | − <div className="inline-flex rounded-md border border-border/80 bg-muted/40 p-0.5"> | |
| 605 | − {normalizedTerminalIds.map((terminalId) => { | |
| 606 | − const isActive = terminalId === resolvedActiveTerminalId; | |
| 607 | − return ( | |
| 696 | + <div className="min-h-0 w-full flex-1"> | |
| 697 | + <div className={`flex h-full min-h-0 ${hasTerminalSidebar ? "gap-1.5" : ""}`}> | |
| 698 | + <div className="min-w-0 flex-1"> | |
| 699 | + {isSplitView ? ( | |
| 700 | + <div | |
| 701 | + className="grid h-full w-full gap-0 overflow-x-auto" | |
| 702 | + style={{ | |
| 703 | + gridTemplateColumns: `repeat(${visibleTerminalIds.length}, minmax(260px, 1fr))`, | |
| 704 | + }} | |
| 705 | + > | |
| 706 | + {visibleTerminalIds.map((terminalId) => ( | |
| 608 | 707 | <div |
| 609 | 708 | key={terminalId} |
| 610 | − className={`inline-flex items-center gap-1 rounded px-2.5 py-1 text-xs transition-colors ${ | |
| 611 | − isActive | |
| 612 | − ? "bg-background text-foreground shadow-sm" | |
| 613 | − : "text-muted-foreground" | |
| 709 | + className={`min-h-0 min-w-0 border-l first:border-l-0 ${ | |
| 710 | + terminalId === resolvedActiveTerminalId | |
| 711 | + ? "border-border" | |
| 712 | + : "border-border/70" | |
| 614 | 713 | }`} |
| 714 | + onMouseDown={() => { | |
| 715 | + if (terminalId !== resolvedActiveTerminalId) { | |
| 716 | + onActiveTerminalChange(terminalId); | |
| 717 | + } | |
| 718 | + }} | |
| 615 | 719 | > |
| 616 | − <button | |
| 617 | − type="button" | |
| 618 | − className="pr-1 hover:text-foreground" | |
| 619 | − onClick={() => onActiveTerminalChange(terminalId)} | |
| 620 | − > | |
| 621 | − {terminalLabelById.get(terminalId) ?? "Terminal"} | |
| 622 | − </button> | |
| 623 | − <button | |
| 624 | − type="button" | |
| 625 | − className="rounded px-1 text-[11px] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground" | |
| 626 | − onClick={(event) => { | |
| 627 | − event.stopPropagation(); | |
| 628 | − onCloseTerminal(terminalId); | |
| 629 | − }} | |
| 630 | − aria-label={`Close ${terminalLabelById.get(terminalId) ?? "terminal"}`} | |
| 631 | − title={`Close ${terminalLabelById.get(terminalId) ?? "terminal"}`} | |
| 632 | − > | |
| 633 | − × | |
| 634 | − </button> | |
| 720 | + <div className="h-full p-1"> | |
| 721 | + <TerminalViewport | |
| 722 | + api={api} | |
| 723 | + threadId={threadId} | |
| 724 | + terminalId={terminalId} | |
| 725 | + cwd={cwd} | |
| 726 | + focusRequestId={focusRequestId} | |
| 727 | + autoFocus={terminalId === resolvedActiveTerminalId} | |
| 728 | + resizeEpoch={resizeEpoch} | |
| 729 | + drawerHeight={drawerHeight} | |
| 730 | + /> | |
| 731 | + </div> | |
| 635 | 732 | </div> |
| 636 | − ); | |
| 637 | − })} | |
| 638 | − </div> | |
| 733 | + ))} | |
| 734 | + </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 | 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"> | |
| 672 | − {terminalLayout === "split" && visibleTerminalIds.length === 2 ? ( | |
| 673 | − <div className="grid h-full w-full grid-cols-2 gap-1"> | |
| 674 | − {visibleTerminalIds.map((terminalId) => ( | |
| 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"> | |
| 752 | + {hasTerminalSidebar && ( | |
| 753 | + <aside className="flex w-36 min-w-36 flex-col border border-border/70 bg-muted/10"> | |
| 754 | + <div className="flex h-[22px] items-stretch justify-end border-b border-border/70"> | |
| 755 | + <div className="inline-flex h-full items-stretch"> | |
| 680 | 756 | <button |
| 681 | 757 | type="button" |
| 682 | − className={`text-xs ${ | |
| 683 | − terminalId === resolvedActiveTerminalId | |
| 684 | − ? "text-foreground" | |
| 685 | − : "text-muted-foreground hover:text-foreground" | |
| 686 | − }`} | |
| 687 | − onClick={() => onActiveTerminalChange(terminalId)} | |
| 758 | + className="inline-flex h-full items-center px-1 text-foreground/90 transition-colors hover:bg-accent/70" | |
| 759 | + onClick={onSplitTerminal} | |
| 760 | + aria-label="Split Terminal" | |
| 761 | + title="Split Terminal" | |
| 688 | 762 | > |
| 689 | − {terminalLabelById.get(terminalId) ?? "Terminal"} | |
| 763 | + <SquareSplitHorizontal className="size-3.25" /> | |
| 690 | 764 | </button> |
| 691 | 765 | <button |
| 692 | 766 | 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"}`} | |
| 767 | + className="inline-flex h-full items-center border-l border-border/70 px-1 text-foreground/90 transition-colors hover:bg-accent/70" | |
| 768 | + onClick={onNewTerminal} | |
| 769 | + aria-label="New 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 | 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 | 783 | </div> |
| 713 | 784 | </div> |
| 714 | − ))} | |
| 715 | − </div> | |
| 716 | − ) : ( | |
| 717 | − <TerminalViewport | |
| 718 | − key={resolvedActiveTerminalId} | |
| 719 | − api={api} | |
| 720 | − threadId={threadId} | |
| 721 | − terminalId={resolvedActiveTerminalId} | |
| 722 | − cwd={cwd} | |
| 723 | − focusRequestId={focusRequestId} | |
| 724 | − autoFocus | |
| 725 | − resizeEpoch={resizeEpoch} | |
| 726 | − drawerHeight={drawerHeight} | |
| 727 | − /> | |
| 728 | − )} | |
| 785 | + | |
| 786 | + <div className="min-h-0 flex-1 overflow-y-auto px-1 py-1"> | |
| 787 | + {resolvedTerminalGroups.map((terminalGroup, groupIndex) => { | |
| 788 | + const isGroupActive = terminalGroup.terminalIds.includes(resolvedActiveTerminalId); | |
| 789 | + const groupActiveTerminalId = isGroupActive | |
| 790 | + ? resolvedActiveTerminalId | |
| 791 | + : (terminalGroup.terminalIds[0] ?? resolvedActiveTerminalId); | |
| 792 | + | |
| 793 | + return ( | |
| 794 | + <div key={terminalGroup.id} className="pb-0.5"> | |
| 795 | + {showGroupHeaders && ( | |
| 796 | + <button | |
| 797 | + type="button" | |
| 798 | + className={`flex w-full items-center rounded px-1 py-0.5 text-[10px] uppercase tracking-[0.08em] ${ | |
| 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 | 861 | </div> |
| 730 | 862 | </aside> |
| 731 | 863 | ); |
| @@ -52,8 +52,12 @@ describe("hydratePersistedState", () => { · 51 unmodified lines | ||
| 52 | 52 | expect(hydrated?.threads[0]?.terminalHeight).toBe(DEFAULT_THREAD_TERMINAL_HEIGHT); |
| 53 | 53 | expect(hydrated?.threads[0]?.terminalIds).toEqual([DEFAULT_THREAD_TERMINAL_ID]); |
| 54 | 54 | expect(hydrated?.threads[0]?.activeTerminalId).toBe(DEFAULT_THREAD_TERMINAL_ID); |
| 55 | − expect(hydrated?.threads[0]?.terminalLayout).toBe("single"); | |
| 56 | − expect(hydrated?.threads[0]?.splitTerminalIds).toEqual([]); | |
| 55 | + expect(hydrated?.threads[0]?.terminalGroups).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 | 61 | expect(hydrated?.threads[0]?.messages[0]?.streaming).toBe(false); |
| 58 | 62 | expect(hydrated?.runtimeMode).toBe("full-access"); |
| 59 | 63 | }); |
| @@ -98,8 +102,12 @@ describe("hydratePersistedState", () => { · 38 unmodified lines | ||
| 98 | 102 | expect(hydrated?.threads[0]?.terminalHeight).toBe(DEFAULT_THREAD_TERMINAL_HEIGHT); |
| 99 | 103 | expect(hydrated?.threads[0]?.terminalIds).toEqual([DEFAULT_THREAD_TERMINAL_ID]); |
| 100 | 104 | expect(hydrated?.threads[0]?.activeTerminalId).toBe(DEFAULT_THREAD_TERMINAL_ID); |
| 101 | − expect(hydrated?.threads[0]?.terminalLayout).toBe("single"); | |
| 102 | − expect(hydrated?.threads[0]?.splitTerminalIds).toEqual([]); | |
| 105 | + expect(hydrated?.threads[0]?.terminalGroups).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 | 111 | expect(hydrated?.activeThreadId).toBe("t-1"); |
| 104 | 112 | expect(hydrated?.runtimeMode).toBe("full-access"); |
| 105 | 113 | }); |
| @@ -125,7 +133,7 @@ describe("hydratePersistedState", () => { · 19 unmodified lines | ||
| 125 | 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 | 137 | const payload = JSON.stringify({ |
| 130 | 138 | version: 6, |
| 131 | 139 | runtimeMode: "full-access", |
| @@ -164,11 +172,60 @@ describe("hydratePersistedState", () => { · 32 unmodified lines | ||
| 164 | 172 | expect(hydrated?.threads[0]?.terminalHeight).toBe(360); |
| 165 | 173 | expect(hydrated?.threads[0]?.terminalIds).toEqual([DEFAULT_THREAD_TERMINAL_ID, "term-2"]); |
| 166 | 174 | expect(hydrated?.threads[0]?.activeTerminalId).toBe("term-2"); |
| 167 | − expect(hydrated?.threads[0]?.terminalLayout).toBe("tabs"); | |
| 168 | − expect(hydrated?.threads[0]?.splitTerminalIds).toEqual([]); | |
| 175 | + expect(hydrated?.threads[0]?.terminalGroups).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 | 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 | 229 | it("defaults terminalHeight when hydrating v5 payloads", () => { |
| 173 | 230 | const payload = JSON.stringify({ |
| 174 | 231 | version: 5, |
| @@ -201,12 +258,17 @@ describe("hydratePersistedState", () => { · 26 unmodified lines | ||
| 201 | 258 | expect(hydrated?.threads[0]?.terminalHeight).toBe(DEFAULT_THREAD_TERMINAL_HEIGHT); |
| 202 | 259 | expect(hydrated?.threads[0]?.terminalIds).toEqual([DEFAULT_THREAD_TERMINAL_ID]); |
| 203 | 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 | 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 | 272 | const thread: Thread = { |
| 211 | 273 | id: "t-1", |
| 212 | 274 | codexThreadId: "thr_1", |
| @@ -217,8 +279,11 @@ describe("toPersistedState", () => { · 4 unmodified lines | ||
| 217 | 279 | terminalHeight: 320, |
| 218 | 280 | terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2"], |
| 219 | 281 | activeTerminalId: "term-2", |
| 220 | − terminalLayout: "tabs", | |
| 221 | − splitTerminalIds: [], | |
| 282 | + terminalGroups: [ | |
| 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 | 287 | session: null, |
| 223 | 288 | messages: [ |
| 224 | 289 | { |
| @@ -262,7 +327,7 @@ describe("toPersistedState", () => { · 37 unmodified lines | ||
| 262 | 327 | runtimeMode: "full-access", |
| 263 | 328 | }); |
| 264 | 329 | |
| 265 | − expect(persisted.version).toBe(6); | |
| 330 | + expect(persisted.version).toBe(7); | |
| 266 | 331 | expect(persisted.runtimeMode).toBe("full-access"); |
| 267 | 332 | expect(persisted.threads[0]).toEqual({ |
| 268 | 333 | id: "t-1", |
| @@ -274,8 +339,11 @@ describe("toPersistedState", () => { · 5 unmodified lines | ||
| 274 | 339 | terminalHeight: 320, |
| 275 | 340 | terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2"], |
| 276 | 341 | activeTerminalId: "term-2", |
| 277 | − terminalLayout: "tabs", | |
| 278 | − splitTerminalIds: [], | |
| 342 | + terminalGroups: [ | |
| 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 | 347 | messages: [ |
| 280 | 348 | { |
| 281 | 349 | id: "m-1", |
| @@ -8,6 +8,7 @@ import { · 7 unmodified lines | ||
| 8 | 8 | type Project, |
| 9 | 9 | type RuntimeMode, |
| 10 | 10 | type Thread, |
| 11 | + type ThreadTerminalGroup, | |
| 11 | 12 | } from "./types"; |
| 12 | 13 | |
| 13 | 14 | const LEGACY_DEFAULT_MODEL = "gpt-5.2-codex"; |
| @@ -39,6 +40,11 @@ const persistedMessageSchema = z.object({ · 25 unmodified lines | ||
| 39 | 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 | 48 | const persistedThreadSchema = z.object({ |
| 43 | 49 | id: z.string().min(1), |
| 44 | 50 | codexThreadId: z.string().min(1).nullable().default(null), |
| @@ -51,8 +57,11 @@ const persistedThreadSchema = z.object({ · 6 unmodified lines | ||
| 51 | 57 | ), |
| 52 | 58 | terminalIds: z.array(z.string().trim().min(1)).default([DEFAULT_THREAD_TERMINAL_ID]), |
| 53 | 59 | activeTerminalId: z.string().trim().min(1).default(DEFAULT_THREAD_TERMINAL_ID), |
| 54 | − terminalLayout: z.enum(["single", "split", "tabs"]).default("single"), | |
| 55 | − splitTerminalIds: z.array(z.string().trim().min(1)).default([]), | |
| 60 | + terminalGroups: z.array(persistedTerminalGroupSchema).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 | 65 | messages: z.array(persistedMessageSchema), |
| 57 | 66 | createdAt: z.string().min(1), |
| 58 | 67 | lastVisitedAt: z.string().min(1).optional(), |
| @@ -91,12 +100,18 @@ export const persistedStateV6Schema = persistedStateBodySchema.extend({ · 32 unmodified lines | ||
| 91 | 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 | 108 | export const persistedStateV5Schema = persistedStateBodySchema.extend({ |
| 95 | 109 | runtimeMode: runtimeModeSchema.default(DEFAULT_RUNTIME_MODE), |
| 96 | 110 | version: z.literal(5).optional(), |
| 97 | 111 | }); |
| 98 | 112 | |
| 99 | 113 | const persistedStateSchema = z.union([ |
| 114 | + persistedStateV7Schema, | |
| 100 | 115 | persistedStateV6Schema, |
| 101 | 116 | persistedStateV5Schema, |
| 102 | 117 | persistedStateV4Schema, |
| @@ -134,33 +149,91 @@ function hydrateThread( · 31 unmodified lines | ||
| 134 | 149 | thread: z.infer<typeof persistedThreadSchema>, |
| 135 | 150 | isLegacyPayload: boolean, |
| 136 | 151 | ): Thread { |
| 137 | − const terminalIds = [...new Set(thread.terminalIds)] | |
| 138 | − .map((id) => id.trim()) | |
| 139 | − .filter((id) => id.length > 0); | |
| 152 | + const terminalIds = [...new Set(thread.terminalIds.map((id) => id.trim()).filter((id) => id.length > 0))]; | |
| 140 | 153 | const safeTerminalIds = |
| 141 | 154 | terminalIds.length > 0 ? terminalIds : [DEFAULT_THREAD_TERMINAL_ID]; |
| 142 | 155 | const activeTerminalId = safeTerminalIds.includes(thread.activeTerminalId) |
| 143 | 156 | ? thread.activeTerminalId |
| 144 | 157 | : (safeTerminalIds[0] ?? DEFAULT_THREAD_TERMINAL_ID); |
| 145 | − let terminalLayout = thread.terminalLayout; | |
| 146 | − let splitTerminalIds = [...new Set(thread.splitTerminalIds)].filter((id) => | |
| 147 | − safeTerminalIds.includes(id), | |
| 148 | − ); | |
| 149 | − if (terminalLayout === "split") { | |
| 150 | − splitTerminalIds = splitTerminalIds.slice(0, 2); | |
| 151 | − if (splitTerminalIds.length < 2) { | |
| 152 | − terminalLayout = safeTerminalIds.length > 1 ? "tabs" : "single"; | |
| 153 | − splitTerminalIds = []; | |
| 158 | + const safeTerminalIdSet = new Set(safeTerminalIds); | |
| 159 | + const assignedTerminalIds = new Set<string>(); | |
| 160 | + const usedGroupIds = new Set<string>(); | |
| 161 | + const normalizedGroups: ThreadTerminalGroup[] = []; | |
| 162 | + const assignUniqueGroupId = (groupId: string): string => { | |
| 163 | + if (!usedGroupIds.has(groupId)) { | |
| 164 | + usedGroupIds.add(groupId); | |
| 165 | + return groupId; | |
| 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) { | |
| 159 | − terminalLayout = "tabs"; | |
| 160 | − } else if (terminalLayout === "tabs" && safeTerminalIds.length < 2) { | |
| 161 | − terminalLayout = "single"; | |
| 213 | + | |
| 214 | + for (const terminalId of safeTerminalIds) { | |
| 215 | + if (assignedTerminalIds.has(terminalId)) continue; | |
| 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 | 237 | return { |
| 165 | 238 | id: thread.id, |
| 166 | 239 | codexThreadId: thread.codexThreadId, |
| @@ -171,8 +244,8 @@ function hydrateThread( · 4 unmodified lines | ||
| 171 | 244 | terminalHeight: thread.terminalHeight ?? DEFAULT_THREAD_TERMINAL_HEIGHT, |
| 172 | 245 | terminalIds: safeTerminalIds, |
| 173 | 246 | activeTerminalId, |
| 174 | − terminalLayout, | |
| 175 | − splitTerminalIds, | |
| 247 | + terminalGroups: normalizedGroups, | |
| 248 | + activeTerminalGroupId, | |
| 176 | 249 | session: null, |
| 177 | 250 | messages: thread.messages.map((message) => { |
| 178 | 251 | const hydratedAttachments = message.attachments?.map((attachment) => ({ ...attachment })); |
| @@ -235,9 +308,9 @@ export function hydratePersistedState( · 56 unmodified lines | ||
| 235 | 308 | |
| 236 | 309 | export function toPersistedState( |
| 237 | 310 | state: PersistedStoreSnapshot, |
| 238 | −): z.infer<typeof persistedStateV6Schema> { | |
| 311 | +): z.infer<typeof persistedStateV7Schema> { | |
| 239 | 312 | return { |
| 240 | − version: 6, | |
| 313 | + version: 7, | |
| 241 | 314 | projects: state.projects, |
| 242 | 315 | threads: state.threads.map((thread) => ({ |
| 243 | 316 | id: thread.id, |
| @@ -249,8 +322,8 @@ export function toPersistedState( · 5 unmodified lines | ||
| 249 | 322 | terminalHeight: thread.terminalHeight, |
| 250 | 323 | terminalIds: thread.terminalIds, |
| 251 | 324 | activeTerminalId: thread.activeTerminalId, |
| 252 | − terminalLayout: thread.terminalLayout, | |
| 253 | − splitTerminalIds: thread.splitTerminalIds, | |
| 325 | + terminalGroups: thread.terminalGroups, | |
| 326 | + activeTerminalGroupId: thread.activeTerminalGroupId, | |
| 254 | 327 | messages: thread.messages.map((message) => ({ |
| 255 | 328 | id: message.id, |
| 256 | 329 | role: message.role, |
| @@ -39,8 +39,13 @@ function makeThread(overrides: Partial<Thread> = {}): Thread { · 38 unmodified lines | ||
| 39 | 39 | terminalHeight: DEFAULT_THREAD_TERMINAL_HEIGHT, |
| 40 | 40 | terminalIds: [DEFAULT_THREAD_TERMINAL_ID], |
| 41 | 41 | activeTerminalId: DEFAULT_THREAD_TERMINAL_ID, |
| 42 | − terminalLayout: "single", | |
| 43 | − splitTerminalIds: [], | |
| 42 | + terminalGroups: [ | |
| 43 | + { | |
| 44 | + id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, | |
| 45 | + terminalIds: [DEFAULT_THREAD_TERMINAL_ID], | |
| 46 | + }, | |
| 47 | + ], | |
| 48 | + activeTerminalGroupId: `group-${DEFAULT_THREAD_TERMINAL_ID}`, | |
| 44 | 49 | session: makeSession(), |
| 45 | 50 | messages: [], |
| 46 | 51 | events: [], |
| @@ -123,10 +128,15 @@ describe("store reducer thread continuity", () => { · 76 unmodified lines | ||
| 123 | 128 | terminalId: "term-2", |
| 124 | 129 | }); |
| 125 | 130 | |
| 126 | − expect(next.threads[0]?.terminalLayout).toBe("split"); | |
| 127 | 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 | 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 | 142 | it("creates a new full-width terminal and switches to tab mode", () => { |
| @@ -137,18 +147,31 @@ describe("store reducer thread continuity", () => { · 4 unmodified lines | ||
| 137 | 147 | terminalId: "term-2", |
| 138 | 148 | }); |
| 139 | 149 | |
| 140 | − expect(next.threads[0]?.terminalLayout).toBe("tabs"); | |
| 141 | 150 | expect(next.threads[0]?.terminalIds).toEqual([DEFAULT_THREAD_TERMINAL_ID, "term-2"]); |
| 142 | 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 | 163 | const state = makeState( |
| 148 | 164 | makeThread({ |
| 149 | − terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2"], | |
| 165 | + terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2", "term-3"], | |
| 150 | 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 | 177 | const next = reducer(state, { |
| @@ -158,14 +181,57 @@ describe("store reducer thread continuity", () => { · 3 unmodified lines | ||
| 158 | 181 | }); |
| 159 | 182 | |
| 160 | 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 | 188 | const state = makeState( |
| 165 | 189 | makeThread({ |
| 166 | 190 | terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2"], |
| 167 | 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 | 237 | const next = reducer(state, { |
| @@ -174,9 +240,15 @@ describe("store reducer thread continuity", () => { · 2 unmodified lines | ||
| 174 | 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 | 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 | 254 | it("closes the final terminal and hides the drawer", () => { |
| @@ -193,7 +265,13 @@ describe("store reducer thread continuity", () => { · 10 unmodified lines | ||
| 193 | 265 | |
| 194 | 266 | expect(next.threads[0]?.terminalOpen).toBe(false); |
| 195 | 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 | 277 | it("backfills codexThreadId from routed provider events", () => { |
| @@ -19,6 +19,7 @@ import { · 18 unmodified lines | ||
| 19 | 19 | type Project, |
| 20 | 20 | type RuntimeMode, |
| 21 | 21 | type Thread, |
| 22 | + type ThreadTerminalGroup, | |
| 22 | 23 | } from "./types"; |
| 23 | 24 | |
| 24 | 25 | // ── Actions ────────────────────────────────────────────────────────── |
| @@ -72,8 +73,9 @@ export interface AppState { · 47 unmodified lines | ||
| 72 | 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 | 77 | const LEGACY_PERSISTED_STATE_KEYS = [ |
| 78 | + "t3code:renderer-state:v6", | |
| 77 | 79 | "t3code:renderer-state:v5", |
| 78 | 80 | "t3code:renderer-state:v4", |
| 79 | 81 | "t3code:renderer-state:v3", |
| @@ -139,9 +141,7 @@ function updateThread( · 59 unmodified lines | ||
| 139 | 141 | } |
| 140 | 142 | |
| 141 | 143 | function normalizeTerminalIds(terminalIds: string[]): string[] { |
| 142 | − const ids = terminalIds | |
| 143 | − .map((id) => id.trim()) | |
| 144 | − .filter((id) => id.length > 0); | |
| 144 | + const ids = terminalIds.map((id) => id.trim()).filter((id) => id.length > 0); | |
| 145 | 145 | const unique = [...new Set(ids)]; |
| 146 | 146 | if (unique.length > 0) { |
| 147 | 147 | return unique; |
| @@ -149,44 +149,112 @@ function normalizeTerminalIds(terminalIds: string[]): string[] { · 1 unmodified line | ||
| 149 | 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 | 230 | function normalizeThreadTerminals(thread: Thread): Thread { |
| 153 | 231 | const terminalIds = normalizeTerminalIds(thread.terminalIds); |
| 154 | 232 | const activeTerminalId = terminalIds.includes(thread.activeTerminalId) |
| 155 | 233 | ? thread.activeTerminalId |
| 156 | 234 | : (terminalIds[0] ?? DEFAULT_THREAD_TERMINAL_ID); |
| 157 | − | |
| 158 | − if (thread.terminalLayout === "split") { | |
| 159 | − const validSplitIds = [...new Set(thread.splitTerminalIds)].filter((id) => | |
| 160 | − terminalIds.includes(id), | |
| 161 | − ); | |
| 162 | − if (validSplitIds.length >= 2) { | |
| 163 | − const splitTerminalIds = validSplitIds.slice(0, 2); | |
| 164 | − const splitActiveTerminalId = splitTerminalIds.includes(activeTerminalId) | |
| 165 | − ? activeTerminalId | |
| 166 | − : (splitTerminalIds[1] ?? splitTerminalIds[0] ?? activeTerminalId); | |
| 167 | − return { | |
| 168 | − ...thread, | |
| 169 | − terminalIds, | |
| 170 | − activeTerminalId: splitActiveTerminalId, | |
| 171 | − terminalLayout: "split", | |
| 172 | − splitTerminalIds, | |
| 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); | |
| 235 | + const terminalGroups = normalizeTerminalGroups(thread, terminalIds); | |
| 236 | + const activeGroupIndexFromId = terminalGroups.findIndex( | |
| 237 | + (group) => group.id === thread.activeTerminalGroupId, | |
| 238 | + ); | |
| 239 | + const activeGroupIndexFromTerminal = findGroupIndexByTerminalId( | |
| 240 | + terminalGroups, | |
| 241 | + activeTerminalId, | |
| 242 | + ); | |
| 243 | + const activeGroupIndex = | |
| 244 | + activeGroupIndexFromId >= 0 | |
| 245 | + ? activeGroupIndexFromId | |
| 246 | + : (activeGroupIndexFromTerminal >= 0 ? activeGroupIndexFromTerminal : 0); | |
| 247 | + const activeTerminalGroupId = | |
| 248 | + terminalGroups[activeGroupIndex]?.id ?? | |
| 249 | + terminalGroups[0]?.id ?? | |
| 250 | + fallbackGroupId(activeTerminalId); | |
| 183 | 251 | |
| 184 | 252 | return { |
| 185 | 253 | ...thread, |
| 186 | 254 | terminalIds, |
| 187 | 255 | activeTerminalId, |
| 188 | − terminalLayout, | |
| 189 | − splitTerminalIds: [], | |
| 256 | + terminalGroups, | |
| 257 | + activeTerminalGroupId, | |
| 190 | 258 | }; |
| 191 | 259 | } |
| 192 | 260 | |
| @@ -202,33 +270,47 @@ function closeThreadTerminal(thread: Thread, terminalId: string): Thread { · 9 unmodified lines | ||
| 202 | 270 | terminalOpen: false, |
| 203 | 271 | terminalIds: [DEFAULT_THREAD_TERMINAL_ID], |
| 204 | 272 | activeTerminalId: DEFAULT_THREAD_TERMINAL_ID, |
| 205 | − terminalLayout: "single", | |
| 206 | − splitTerminalIds: [], | |
| 273 | + terminalGroups: [ | |
| 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 | 293 | const nextActiveTerminalId = |
| 211 | 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 | 301 | : 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 | − } | |
| 302 | + const nextTerminalGroups = thread.terminalGroups | |
| 303 | + .map((group) => ({ | |
| 304 | + ...group, | |
| 305 | + terminalIds: group.terminalIds.filter((id) => id !== terminalId), | |
| 306 | + })) | |
| 307 | + .filter((group) => group.terminalIds.length > 0); | |
| 225 | 308 | |
| 226 | 309 | return normalizeThreadTerminals({ |
| 227 | 310 | ...thread, |
| 228 | 311 | terminalIds: remainingTerminalIds, |
| 229 | 312 | activeTerminalId: nextActiveTerminalId, |
| 230 | − terminalLayout: nextLayout, | |
| 231 | − splitTerminalIds: nextSplitTerminalIds, | |
| 313 | + terminalGroups: nextTerminalGroups, | |
| 232 | 314 | }); |
| 233 | 315 | } |
| 234 | 316 | |
| @@ -440,28 +522,67 @@ export function reducer(state: AppState, action: Action): AppState { · 205 unmodified lines | ||
| 440 | 522 | return { |
| 441 | 523 | ...state, |
| 442 | 524 | threads: updateThread(state.threads, action.threadId, (thread) => { |
| 443 | − const terminalIds = thread.terminalIds.includes(action.terminalId) | |
| 444 | − ? thread.terminalIds | |
| 445 | − : [...thread.terminalIds, action.terminalId]; | |
| 446 | − const firstTerminalId = | |
| 447 | − terminalIds.includes(thread.activeTerminalId) | |
| 448 | − ? thread.activeTerminalId | |
| 449 | − : (terminalIds[0] ?? DEFAULT_THREAD_TERMINAL_ID); | |
| 450 | − const secondTerminalId = | |
| 451 | − action.terminalId === firstTerminalId | |
| 452 | − ? (terminalIds.find((id) => id !== firstTerminalId) ?? firstTerminalId) | |
| 453 | − : action.terminalId; | |
| 454 | − const splitTerminalIds = | |
| 455 | − firstTerminalId === secondTerminalId | |
| 456 | − ? [] | |
| 457 | − : [firstTerminalId, secondTerminalId]; | |
| 525 | + const normalizedThread = normalizeThreadTerminals(thread); | |
| 526 | + const terminalIds = normalizedThread.terminalIds.includes(action.terminalId) | |
| 527 | + ? normalizedThread.terminalIds | |
| 528 | + : [...normalizedThread.terminalIds, action.terminalId]; | |
| 529 | + const terminalGroups = normalizedThread.terminalGroups.map((group) => ({ | |
| 530 | + ...group, | |
| 531 | + terminalIds: [...group.terminalIds], | |
| 532 | + })); | |
| 533 | + let activeGroupIndex = terminalGroups.findIndex( | |
| 534 | + (group) => group.id === normalizedThread.activeTerminalGroupId, | |
| 535 | + ); | |
| 536 | + if (activeGroupIndex < 0) { | |
| 537 | + activeGroupIndex = findGroupIndexByTerminalId( | |
| 538 | + terminalGroups, | |
| 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 | 580 | return normalizeThreadTerminals({ |
| 460 | − ...thread, | |
| 581 | + ...normalizedThread, | |
| 461 | 582 | terminalIds, |
| 462 | − activeTerminalId: secondTerminalId, | |
| 463 | − terminalLayout: splitTerminalIds.length === 2 ? "split" : "single", | |
| 464 | − splitTerminalIds, | |
| 583 | + activeTerminalId: action.terminalId, | |
| 584 | + activeTerminalGroupId: destinationGroup.id, | |
| 585 | + terminalGroups, | |
| 465 | 586 | }); |
| 466 | 587 | }), |
| 467 | 588 | }; |
| @@ -470,15 +591,25 @@ export function reducer(state: AppState, action: Action): AppState { · 2 unmodified lines | ||
| 470 | 591 | return { |
| 471 | 592 | ...state, |
| 472 | 593 | threads: updateThread(state.threads, action.threadId, (thread) => { |
| 473 | − const terminalIds = thread.terminalIds.includes(action.terminalId) | |
| 474 | − ? thread.terminalIds | |
| 475 | − : [...thread.terminalIds, action.terminalId]; | |
| 594 | + const normalizedThread = normalizeThreadTerminals(thread); | |
| 595 | + const terminalIds = normalizedThread.terminalIds.includes(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 | 607 | return normalizeThreadTerminals({ |
| 477 | − ...thread, | |
| 608 | + ...normalizedThread, | |
| 478 | 609 | terminalIds, |
| 479 | 610 | activeTerminalId: action.terminalId, |
| 480 | − terminalLayout: "tabs", | |
| 481 | − splitTerminalIds: [], | |
| 611 | + activeTerminalGroupId: nextGroupId, | |
| 612 | + terminalGroups, | |
| 482 | 613 | }); |
| 483 | 614 | }), |
| 484 | 615 | }; |
| @@ -487,12 +618,23 @@ export function reducer(state: AppState, action: Action): AppState { · 2 unmodified lines | ||
| 487 | 618 | return { |
| 488 | 619 | ...state, |
| 489 | 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 | 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 | 634 | return normalizeThreadTerminals({ |
| 494 | − ...thread, | |
| 635 | + ...normalizedThread, | |
| 495 | 636 | activeTerminalId: action.terminalId, |
| 637 | + activeTerminalGroupId, | |
| 496 | 638 | }); |
| 497 | 639 | }), |
| 498 | 640 | }; |
| @@ -5,7 +5,11 @@ export type RuntimeMode = "approval-required" | "full-access"; · 4 unmodified lines | ||
| 5 | 5 | export const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access"; |
| 6 | 6 | export const DEFAULT_THREAD_TERMINAL_HEIGHT = 280; |
| 7 | 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 | 14 | export interface ChatImageAttachment { |
| 11 | 15 | type: "image"; |
| @@ -45,8 +49,8 @@ export interface Thread { · 33 unmodified lines | ||
| 45 | 49 | terminalHeight: number; |
| 46 | 50 | terminalIds: string[]; |
| 47 | 51 | activeTerminalId: string; |
| 48 | − terminalLayout: ThreadTerminalLayout; | |
| 49 | − splitTerminalIds: string[]; | |
| 52 | + terminalGroups: ThreadTerminalGroup[]; | |
| 53 | + activeTerminalGroupId: string; | |
| 50 | 54 | session: ProviderSession | null; |
| 51 | 55 | messages: ChatMessage[]; |
| 52 | 56 | events: ProviderEvent[]; |