authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-12 21:06:03-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-02-12 21:24:49-08:00
log07210d0c912398f405a318cda7ea627126616c82
treede6908183e32a1aee43d3f70fc0e84c9b4191e67
parentee463f6b76a9217fa1d5eb03519c039bed3db1cb

Migrate thread terminals to grouped layout model

- 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

9 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 @@
1import { FitAddon } from "@xterm/addon-fit";1import { FitAddon } from "@xterm/addon-fit";
2import { Plus, SquareSplitHorizontal, TerminalSquare, Trash2 } from "lucide-react";
2import { type NativeApi } from "@t3tools/contracts";3import { type NativeApi } from "@t3tools/contracts";
3import { Terminal, type ITheme } from "@xterm/xterm";4import { Terminal, type ITheme } from "@xterm/xterm";
4import {5import {
...@@ -19,7 +20,7 @@ import { isTerminalClearShortcut } from "../terminal-shortcuts";...@@ -19,7 +20,7 @@ import { isTerminalClearShortcut } from "../terminal-shortcuts";
19import {20import {
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";
2425
25const MIN_DRAWER_HEIGHT = 180;26const 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 ? activeTerminalId463 ? activeTerminalId
463 : (normalizedTerminalIds[0] ?? DEFAULT_THREAD_TERMINAL_ID);464 : (normalizedTerminalIds[0] ?? DEFAULT_THREAD_TERMINAL_ID);
464465
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 }
473509
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]);
480517
481 const visibleTerminalIds =518 if (nextGroups.length > 0) {
482 terminalLayout === "split" && resolvedSplitTerminalIds.length === 2519 return nextGroups;
483 ? resolvedSplitTerminalIds520 }
484 : [resolvedActiveTerminalId];
485521
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({
585646
586 return (647 return (
587 <aside648 <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 <div652 <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 )}
600695
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 <div707 <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 isActive710 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 <button720 <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 <button727 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>
670751
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 <button756 <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 === resolvedActiveTerminalId759 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 <button765 <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 <TerminalViewport788 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 autoFocus795 {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 });
127135
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 });
171182
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});
207269
208describe("toPersistedState", () => {270describe("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 });
264329
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";
1213
13const LEGACY_DEFAULT_MODEL = "gpt-5.2-codex";14const 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});
4142
43const persistedTerminalGroupSchema = z.object({
44 id: z.string().trim().min(1),
45 terminalIds: z.array(z.string().trim().min(1)),
46});
47
42const persistedThreadSchema = z.object({48const 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});
93102
103export const persistedStateV7Schema = persistedStateBodySchema.extend({
104 runtimeMode: runtimeModeSchema.default(DEFAULT_RUNTIME_MODE),
105 version: z.literal(7).optional(),
106});
107
94export const persistedStateV5Schema = persistedStateBodySchema.extend({108export 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});
98112
99const persistedStateSchema = z.union([113const 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.activeTerminalId156 ? 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 }
163221
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(
235308
236export function toPersistedState(309export 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 });
125130
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 });
131141
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 });
139149
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 });
145161
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 });
159182
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 });
162186
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 });
176242
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 });
181253
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", () => {
193265
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 });
198276
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";
2324
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}
7475
75const PERSISTED_STATE_KEY = "t3code:renderer-state:v6";76const PERSISTED_STATE_KEY = "t3code:renderer-state:v7";
76const LEGACY_PERSISTED_STATE_KEYS = [77const 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}
140142
141function normalizeTerminalIds(terminalIds: string[]): string[] {143function normalizeTerminalIds(terminalIds: string[]): string[] {
142 const ids = terminalIds144 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}
151151
152function normalizeTerminalGroupIds(terminalIds: string[]): string[] {
153 return [...new Set(terminalIds.map((id) => id.trim()).filter((id) => id.length > 0))];
154}
155
156function fallbackGroupId(terminalId: string): string {
157 return `group-${terminalId}`;
158}
159
160function 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
174function 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
223function findGroupIndexByTerminalId(
224 terminalGroups: ThreadTerminalGroup[],
225 terminalId: string,
226): number {
227 return terminalGroups.findIndex((group) => group.terminalIds.includes(terminalId));
228}
229
152function normalizeThreadTerminals(thread: Thread): Thread {230function 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.activeTerminalId233 ? thread.activeTerminalId
156 : (terminalIds[0] ?? DEFAULT_THREAD_TERMINAL_ID);234 : (terminalIds[0] ?? DEFAULT_THREAD_TERMINAL_ID);
157235 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 ? activeTerminalId243 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);
183251
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}
192260
...@@ -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 }
209282
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 === terminalId294 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) => ({
216304 ...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 }
225308
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}
234316
...@@ -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.terminalIds526 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.activeTerminalId530 ...group,
449 : (terminalIds[0] ?? DEFAULT_THREAD_TERMINAL_ID);531 terminalIds: [...group.terminalIds],
450 const secondTerminalId =532 }));
451 action.terminalId === firstTerminalId533 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 === secondTerminalId537 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 }
458549
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.terminalIds595 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";
5export const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access";5export const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access";
6export const DEFAULT_THREAD_TERMINAL_HEIGHT = 280;6export const DEFAULT_THREAD_TERMINAL_HEIGHT = 280;
7export const DEFAULT_THREAD_TERMINAL_ID = "default";7export const DEFAULT_THREAD_TERMINAL_ID = "default";
8export type ThreadTerminalLayout = "single" | "split" | "tabs";8
9export interface ThreadTerminalGroup {
10 id: string;
11 terminalIds: string[];
12}
913
10export interface ChatImageAttachment {14export 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[];