authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-03-12 11:41:13-07:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-03-12 11:50:35-07:00
logc52ad29b31e890164d0e22443f36c1e39483d3bc
tree7338d975f87295e5297953304c179af735eb26b1
parentfcbf3f3c225ab41c44af3759ae27d8520ced61a4

Fix mod+N new thread flow and terminal split limits

- ensure `chat.new` creates a fresh draft after a promoted draft thread - enforce terminal cap per split group (4) while allowing additional terminal groups - refine sidebar row selected/active styling via shared class-name logic and tests

10 files changed, 306 insertions(+), 73 deletions(-)

apps/web/src/components/ChatView.browser.tsx+125
...@@ -20,6 +20,7 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi }...@@ -20,6 +20,7 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi }
20import { render } from "vitest-browser-react";20import { render } from "vitest-browser-react";
2121
22import { useComposerDraftStore } from "../composerDraftStore";22import { useComposerDraftStore } from "../composerDraftStore";
23import { isMacPlatform } from "../lib/utils";
23import { getRouter } from "../router";24import { getRouter } from "../router";
24import { useStore } from "../store";25import { useStore } from "../store";
25import { estimateTimelineMessageHeight } from "./timelineHeight";26import { estimateTimelineMessageHeight } from "./timelineHeight";
...@@ -1062,6 +1063,130 @@ describe("ChatView timeline estimator parity (full app)", () => {...@@ -1062,6 +1063,130 @@ describe("ChatView timeline estimator parity (full app)", () => {
1062 }1063 }
1063 });1064 });
10641065
1066 it("creates a new thread from the global chat.new shortcut", async () => {
1067 const mounted = await mountChatView({
1068 viewport: DEFAULT_VIEWPORT,
1069 snapshot: createSnapshotForTargetUser({
1070 targetMessageId: "msg-user-chat-shortcut-test" as MessageId,
1071 targetText: "chat shortcut test",
1072 }),
1073 configureFixture: (nextFixture) => {
1074 nextFixture.serverConfig = {
1075 ...nextFixture.serverConfig,
1076 keybindings: [
1077 {
1078 command: "chat.new",
1079 shortcut: {
1080 key: "o",
1081 metaKey: false,
1082 ctrlKey: false,
1083 shiftKey: true,
1084 altKey: false,
1085 modKey: true,
1086 },
1087 whenAst: {
1088 type: "not",
1089 node: { type: "identifier", name: "terminalFocus" },
1090 },
1091 },
1092 ],
1093 };
1094 },
1095 });
1096
1097 try {
1098 const useMetaForMod = isMacPlatform(navigator.platform);
1099 window.dispatchEvent(
1100 new KeyboardEvent("keydown", {
1101 key: "o",
1102 shiftKey: true,
1103 metaKey: useMetaForMod,
1104 ctrlKey: !useMetaForMod,
1105 bubbles: true,
1106 cancelable: true,
1107 }),
1108 );
1109
1110 await waitForURL(
1111 mounted.router,
1112 (path) => UUID_ROUTE_RE.test(path),
1113 "Route should have changed to a new draft thread UUID from the shortcut.",
1114 );
1115 } finally {
1116 await mounted.cleanup();
1117 }
1118 });
1119
1120 it("creates a fresh draft after the previous draft thread is promoted", async () => {
1121 const mounted = await mountChatView({
1122 viewport: DEFAULT_VIEWPORT,
1123 snapshot: createSnapshotForTargetUser({
1124 targetMessageId: "msg-user-promoted-draft-shortcut-test" as MessageId,
1125 targetText: "promoted draft shortcut test",
1126 }),
1127 configureFixture: (nextFixture) => {
1128 nextFixture.serverConfig = {
1129 ...nextFixture.serverConfig,
1130 keybindings: [
1131 {
1132 command: "chat.new",
1133 shortcut: {
1134 key: "o",
1135 metaKey: false,
1136 ctrlKey: false,
1137 shiftKey: true,
1138 altKey: false,
1139 modKey: true,
1140 },
1141 whenAst: {
1142 type: "not",
1143 node: { type: "identifier", name: "terminalFocus" },
1144 },
1145 },
1146 ],
1147 };
1148 },
1149 });
1150
1151 try {
1152 const newThreadButton = page.getByTestId("new-thread-button");
1153 await expect.element(newThreadButton).toBeInTheDocument();
1154 await newThreadButton.click();
1155
1156 const promotedThreadPath = await waitForURL(
1157 mounted.router,
1158 (path) => UUID_ROUTE_RE.test(path),
1159 "Route should have changed to a promoted draft thread UUID.",
1160 );
1161 const promotedThreadId = promotedThreadPath.slice(1) as ThreadId;
1162
1163 const { syncServerReadModel } = useStore.getState();
1164 syncServerReadModel(addThreadToSnapshot(fixture.snapshot, promotedThreadId));
1165 useComposerDraftStore.getState().clearDraftThread(promotedThreadId);
1166
1167 const useMetaForMod = isMacPlatform(navigator.platform);
1168 window.dispatchEvent(
1169 new KeyboardEvent("keydown", {
1170 key: "o",
1171 shiftKey: true,
1172 metaKey: useMetaForMod,
1173 ctrlKey: !useMetaForMod,
1174 bubbles: true,
1175 cancelable: true,
1176 }),
1177 );
1178
1179 const freshThreadPath = await waitForURL(
1180 mounted.router,
1181 (path) => UUID_ROUTE_RE.test(path) && path !== promotedThreadPath,
1182 "Shortcut should create a fresh draft instead of reusing the promoted thread.",
1183 );
1184 expect(freshThreadPath).not.toBe(promotedThreadPath);
1185 } finally {
1186 await mounted.cleanup();
1187 }
1188 });
1189
1065 it("keeps long proposed plans lightweight until the user expands them", async () => {1190 it("keeps long proposed plans lightweight until the user expands them", async () => {
1066 const mounted = await mountChatView({1191 const mounted = await mountChatView({
1067 viewport: DEFAULT_VIEWPORT,1192 viewport: DEFAULT_VIEWPORT,
apps/web/src/components/ChatView.tsx+16-8
...@@ -76,7 +76,7 @@ import {...@@ -76,7 +76,7 @@ import {
76 DEFAULT_INTERACTION_MODE,76 DEFAULT_INTERACTION_MODE,
77 DEFAULT_RUNTIME_MODE,77 DEFAULT_RUNTIME_MODE,
78 DEFAULT_THREAD_TERMINAL_ID,78 DEFAULT_THREAD_TERMINAL_ID,
79 MAX_THREAD_TERMINAL_COUNT,79 MAX_TERMINALS_PER_GROUP,
80 type ChatMessage,80 type ChatMessage,
81 type TurnDiffSummary,81 type TurnDiffSummary,
82} from "../types";82} from "../types";
...@@ -1037,7 +1037,16 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1037,7 +1037,16 @@ export default function ChatView({ threadId }: ChatViewProps) {
1037 (activeThread.messages.length > 0 ||1037 (activeThread.messages.length > 0 ||
1038 (activeThread.session !== null && activeThread.session.status !== "closed")),1038 (activeThread.session !== null && activeThread.session.status !== "closed")),
1039 );1039 );
1040 const hasReachedTerminalLimit = terminalState.terminalIds.length >= MAX_THREAD_TERMINAL_COUNT;1040 const activeTerminalGroup =
1041 terminalState.terminalGroups.find(
1042 (group) => group.id === terminalState.activeTerminalGroupId,
1043 ) ??
1044 terminalState.terminalGroups.find((group) =>
1045 group.terminalIds.includes(terminalState.activeTerminalId),
1046 ) ??
1047 null;
1048 const hasReachedSplitLimit =
1049 (activeTerminalGroup?.terminalIds.length ?? 0) >= MAX_TERMINALS_PER_GROUP;
1041 const setThreadError = useCallback(1050 const setThreadError = useCallback(
1042 (targetThreadId: ThreadId | null, error: string | null) => {1051 (targetThreadId: ThreadId | null, error: string | null) => {
1043 if (!targetThreadId) return;1052 if (!targetThreadId) return;
...@@ -1085,17 +1094,17 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1085,17 +1094,17 @@ export default function ChatView({ threadId }: ChatViewProps) {
1085 setTerminalOpen(!terminalState.terminalOpen);1094 setTerminalOpen(!terminalState.terminalOpen);
1086 }, [activeThreadId, setTerminalOpen, terminalState.terminalOpen]);1095 }, [activeThreadId, setTerminalOpen, terminalState.terminalOpen]);
1087 const splitTerminal = useCallback(() => {1096 const splitTerminal = useCallback(() => {
1088 if (!activeThreadId || hasReachedTerminalLimit) return;1097 if (!activeThreadId || hasReachedSplitLimit) return;
1089 const terminalId = `terminal-${randomUUID()}`;1098 const terminalId = `terminal-${randomUUID()}`;
1090 storeSplitTerminal(activeThreadId, terminalId);1099 storeSplitTerminal(activeThreadId, terminalId);
1091 setTerminalFocusRequestId((value) => value + 1);1100 setTerminalFocusRequestId((value) => value + 1);
1092 }, [activeThreadId, storeSplitTerminal, hasReachedTerminalLimit]);1101 }, [activeThreadId, hasReachedSplitLimit, storeSplitTerminal]);
1093 const createNewTerminal = useCallback(() => {1102 const createNewTerminal = useCallback(() => {
1094 if (!activeThreadId || hasReachedTerminalLimit) return;1103 if (!activeThreadId) return;
1095 const terminalId = `terminal-${randomUUID()}`;1104 const terminalId = `terminal-${randomUUID()}`;
1096 storeNewTerminal(activeThreadId, terminalId);1105 storeNewTerminal(activeThreadId, terminalId);
1097 setTerminalFocusRequestId((value) => value + 1);1106 setTerminalFocusRequestId((value) => value + 1);
1098 }, [activeThreadId, storeNewTerminal, hasReachedTerminalLimit]);1107 }, [activeThreadId, storeNewTerminal]);
1099 const activateTerminal = useCallback(1108 const activateTerminal = useCallback(
1100 (terminalId: string) => {1109 (terminalId: string) => {
1101 if (!activeThreadId) return;1110 if (!activeThreadId) return;
...@@ -1162,8 +1171,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1162,8 +1171,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
1162 DEFAULT_THREAD_TERMINAL_ID;1171 DEFAULT_THREAD_TERMINAL_ID;
1163 const isBaseTerminalBusy = terminalState.runningTerminalIds.includes(baseTerminalId);1172 const isBaseTerminalBusy = terminalState.runningTerminalIds.includes(baseTerminalId);
1164 const wantsNewTerminal = Boolean(options?.preferNewTerminal) || isBaseTerminalBusy;1173 const wantsNewTerminal = Boolean(options?.preferNewTerminal) || isBaseTerminalBusy;
1165 const shouldCreateNewTerminal =1174 const shouldCreateNewTerminal = wantsNewTerminal;
1166 wantsNewTerminal && terminalState.terminalIds.length < MAX_THREAD_TERMINAL_COUNT;
1167 const targetTerminalId = shouldCreateNewTerminal1175 const targetTerminalId = shouldCreateNewTerminal
1168 ? `terminal-${randomUUID()}`1176 ? `terminal-${randomUUID()}`
1169 : baseTerminalId;1177 : baseTerminalId;
apps/web/src/components/Sidebar.logic.test.ts+25
...@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";...@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
22
3import {3import {
4 hasUnseenCompletion,4 hasUnseenCompletion,
5 resolveThreadRowClassName,
5 resolveThreadStatusPill,6 resolveThreadStatusPill,
6 shouldClearThreadSelectionOnMouseDown,7 shouldClearThreadSelectionOnMouseDown,
7} from "./Sidebar.logic";8} from "./Sidebar.logic";
...@@ -154,3 +155,27 @@ describe("resolveThreadStatusPill", () => {...@@ -154,3 +155,27 @@ describe("resolveThreadStatusPill", () => {
154 ).toMatchObject({ label: "Completed", pulse: false });155 ).toMatchObject({ label: "Completed", pulse: false });
155 });156 });
156});157});
158
159describe("resolveThreadRowClassName", () => {
160 it("uses the darker selected palette when a thread is both selected and active", () => {
161 const className = resolveThreadRowClassName({ isActive: true, isSelected: true });
162 expect(className).toContain("bg-primary/22");
163 expect(className).toContain("hover:bg-primary/26");
164 expect(className).toContain("dark:bg-primary/30");
165 expect(className).not.toContain("bg-accent/85");
166 });
167
168 it("uses selected hover colors for selected threads", () => {
169 const className = resolveThreadRowClassName({ isActive: false, isSelected: true });
170 expect(className).toContain("bg-primary/15");
171 expect(className).toContain("hover:bg-primary/19");
172 expect(className).toContain("dark:bg-primary/22");
173 expect(className).not.toContain("hover:bg-accent");
174 });
175
176 it("keeps the accent palette for active-only threads", () => {
177 const className = resolveThreadRowClassName({ isActive: true, isSelected: false });
178 expect(className).toContain("bg-accent/85");
179 expect(className).toContain("hover:bg-accent");
180 });
181});
apps/web/src/components/Sidebar.logic.ts+32
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1import type { Thread } from "../types";1import type { Thread } from "../types";
2import { cn } from "../lib/utils";
2import { findLatestProposedPlan, isLatestTurnSettled } from "../session-logic";3import { findLatestProposedPlan, isLatestTurnSettled } from "../session-logic";
34
4export const THREAD_SELECTION_SAFE_SELECTOR = "[data-thread-item], [data-thread-selection-safe]";5export const THREAD_SELECTION_SAFE_SELECTOR = "[data-thread-item], [data-thread-selection-safe]";
...@@ -37,6 +38,37 @@ export function shouldClearThreadSelectionOnMouseDown(target: HTMLElement | null...@@ -37,6 +38,37 @@ export function shouldClearThreadSelectionOnMouseDown(target: HTMLElement | null
37 return !target.closest(THREAD_SELECTION_SAFE_SELECTOR);38 return !target.closest(THREAD_SELECTION_SAFE_SELECTOR);
38}39}
3940
41export function resolveThreadRowClassName(input: {
42 isActive: boolean;
43 isSelected: boolean;
44}): string {
45 const baseClassName =
46 "h-7 w-full translate-x-0 cursor-default justify-start px-2 text-left select-none focus-visible:ring-0";
47
48 if (input.isSelected && input.isActive) {
49 return cn(
50 baseClassName,
51 "bg-primary/22 text-foreground font-medium hover:bg-primary/26 hover:text-foreground dark:bg-primary/30 dark:hover:bg-primary/36",
52 );
53 }
54
55 if (input.isSelected) {
56 return cn(
57 baseClassName,
58 "bg-primary/15 text-foreground hover:bg-primary/19 hover:text-foreground dark:bg-primary/22 dark:hover:bg-primary/28",
59 );
60 }
61
62 if (input.isActive) {
63 return cn(
64 baseClassName,
65 "bg-accent/85 text-foreground font-medium hover:bg-accent hover:text-foreground dark:bg-accent/55 dark:hover:bg-accent/70",
66 );
67 }
68
69 return cn(baseClassName, "text-muted-foreground hover:bg-accent hover:text-foreground");
70}
71
40export function resolveThreadStatusPill(input: {72export function resolveThreadStatusPill(input: {
41 thread: ThreadStatusInput;73 thread: ThreadStatusInput;
42 hasPendingApprovals: boolean;74 hasPendingApprovals: boolean;
apps/web/src/components/Sidebar.tsx+10-9
...@@ -83,7 +83,11 @@ import {...@@ -83,7 +83,11 @@ import {
83import { useThreadSelectionStore } from "../threadSelectionStore";83import { useThreadSelectionStore } from "../threadSelectionStore";
84import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup";84import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup";
85import { isNonEmpty as isNonEmptyString } from "effect/String";85import { isNonEmpty as isNonEmptyString } from "effect/String";
86import { resolveThreadStatusPill, shouldClearThreadSelectionOnMouseDown } from "./Sidebar.logic";86import {
87 resolveThreadRowClassName,
88 resolveThreadStatusPill,
89 shouldClearThreadSelectionOnMouseDown,
90} from "./Sidebar.logic";
8791
88const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = [];92const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = [];
89const THREAD_PREVIEW_LIMIT = 6;93const THREAD_PREVIEW_LIMIT = 6;
...@@ -1397,13 +1401,10 @@ export default function Sidebar() {...@@ -1397,13 +1401,10 @@ export default function Sidebar() {
1397 render={<div role="button" tabIndex={0} />}1401 render={<div role="button" tabIndex={0} />}
1398 size="sm"1402 size="sm"
1399 isActive={isActive}1403 isActive={isActive}
1400 className={`h-7 w-full translate-x-0 cursor-default justify-start px-2 text-left select-none hover:bg-accent hover:text-foreground focus-visible:ring-0 ${1404 className={resolveThreadRowClassName({
1401 isSelected1405 isActive,
1402 ? "bg-primary/15 text-foreground dark:bg-primary/10"1406 isSelected,
1403 : isActive1407 })}
1404 ? "bg-accent/85 text-foreground font-medium dark:bg-accent/55"
1405 : "text-muted-foreground"
1406 }`}
1407 onClick={(event) => {1408 onClick={(event) => {
1408 handleThreadClick(1409 handleThreadClick(
1409 event,1410 event,
...@@ -1541,7 +1542,7 @@ export default function Sidebar() {...@@ -1541,7 +1542,7 @@ export default function Sidebar() {
1541 <span1542 <span
1542 className={`text-[10px] ${1543 className={`text-[10px] ${
1543 isHighlighted1544 isHighlighted
1544 ? "text-foreground/65"1545 ? "text-foreground/72 dark:text-foreground/82"
1545 : "text-muted-foreground/40"1546 : "text-muted-foreground/40"
1546 }`}1547 }`}
1547 >1548 >
apps/web/src/components/ThreadTerminalDrawer.tsx+14-25
...@@ -22,7 +22,7 @@ import { isTerminalClearShortcut, terminalNavigationShortcutData } from "../keyb...@@ -22,7 +22,7 @@ import { isTerminalClearShortcut, terminalNavigationShortcutData } from "../keyb
22import {22import {
23 DEFAULT_THREAD_TERMINAL_HEIGHT,23 DEFAULT_THREAD_TERMINAL_HEIGHT,
24 DEFAULT_THREAD_TERMINAL_ID,24 DEFAULT_THREAD_TERMINAL_ID,
25 MAX_THREAD_TERMINAL_COUNT,25 MAX_TERMINALS_PER_GROUP,
26 type ThreadTerminalGroup,26 type ThreadTerminalGroup,
27} from "../types";27} from "../types";
28import { readNativeApi } from "~/nativeApi";28import { readNativeApi } from "~/nativeApi";
...@@ -605,7 +605,7 @@ export default function ThreadTerminalDrawer({...@@ -605,7 +605,7 @@ export default function ThreadTerminalDrawer({
605 const showGroupHeaders =605 const showGroupHeaders =
606 resolvedTerminalGroups.length > 1 ||606 resolvedTerminalGroups.length > 1 ||
607 resolvedTerminalGroups.some((terminalGroup) => terminalGroup.terminalIds.length > 1);607 resolvedTerminalGroups.some((terminalGroup) => terminalGroup.terminalIds.length > 1);
608 const hasReachedTerminalLimit = normalizedTerminalIds.length >= MAX_THREAD_TERMINAL_COUNT;608 const hasReachedSplitLimit = visibleTerminalIds.length >= MAX_TERMINALS_PER_GROUP;
609 const terminalLabelById = useMemo(609 const terminalLabelById = useMemo(
610 () =>610 () =>
611 new Map(611 new Map(
...@@ -613,27 +613,24 @@ export default function ThreadTerminalDrawer({...@@ -613,27 +613,24 @@ export default function ThreadTerminalDrawer({
613 ),613 ),
614 [normalizedTerminalIds],614 [normalizedTerminalIds],
615 );615 );
616 const splitTerminalActionLabel = hasReachedTerminalLimit616 const splitTerminalActionLabel = hasReachedSplitLimit
617 ? `Split Terminal (max ${MAX_THREAD_TERMINAL_COUNT})`617 ? `Split Terminal (max ${MAX_TERMINALS_PER_GROUP} per group)`
618 : splitShortcutLabel618 : splitShortcutLabel
619 ? `Split Terminal (${splitShortcutLabel})`619 ? `Split Terminal (${splitShortcutLabel})`
620 : "Split Terminal";620 : "Split Terminal";
621 const newTerminalActionLabel = hasReachedTerminalLimit621 const newTerminalActionLabel = newShortcutLabel
622 ? `New Terminal (max ${MAX_THREAD_TERMINAL_COUNT})`622 ? `New Terminal (${newShortcutLabel})`
623 : newShortcutLabel623 : "New Terminal";
624 ? `New Terminal (${newShortcutLabel})`
625 : "New Terminal";
626 const closeTerminalActionLabel = closeShortcutLabel624 const closeTerminalActionLabel = closeShortcutLabel
627 ? `Close Terminal (${closeShortcutLabel})`625 ? `Close Terminal (${closeShortcutLabel})`
628 : "Close Terminal";626 : "Close Terminal";
629 const onSplitTerminalAction = useCallback(() => {627 const onSplitTerminalAction = useCallback(() => {
630 if (hasReachedTerminalLimit) return;628 if (hasReachedSplitLimit) return;
631 onSplitTerminal();629 onSplitTerminal();
632 }, [hasReachedTerminalLimit, onSplitTerminal]);630 }, [hasReachedSplitLimit, onSplitTerminal]);
633 const onNewTerminalAction = useCallback(() => {631 const onNewTerminalAction = useCallback(() => {
634 if (hasReachedTerminalLimit) return;
635 onNewTerminal();632 onNewTerminal();
636 }, [hasReachedTerminalLimit, onNewTerminal]);633 }, [onNewTerminal]);
637634
638 useEffect(() => {635 useEffect(() => {
639 onHeightChangeRef.current = onHeightChange;636 onHeightChangeRef.current = onHeightChange;
...@@ -744,7 +741,7 @@ export default function ThreadTerminalDrawer({...@@ -744,7 +741,7 @@ export default function ThreadTerminalDrawer({
744 <div className="pointer-events-auto inline-flex items-center overflow-hidden rounded-md border border-border/80 bg-background/70">741 <div className="pointer-events-auto inline-flex items-center overflow-hidden rounded-md border border-border/80 bg-background/70">
745 <TerminalActionButton742 <TerminalActionButton
746 className={`p-1 text-foreground/90 transition-colors ${743 className={`p-1 text-foreground/90 transition-colors ${
747 hasReachedTerminalLimit744 hasReachedSplitLimit
748 ? "cursor-not-allowed opacity-45 hover:bg-transparent"745 ? "cursor-not-allowed opacity-45 hover:bg-transparent"
749 : "hover:bg-accent"746 : "hover:bg-accent"
750 }`}747 }`}
...@@ -755,11 +752,7 @@ export default function ThreadTerminalDrawer({...@@ -755,11 +752,7 @@ export default function ThreadTerminalDrawer({
755 </TerminalActionButton>752 </TerminalActionButton>
756 <div className="h-4 w-px bg-border/80" />753 <div className="h-4 w-px bg-border/80" />
757 <TerminalActionButton754 <TerminalActionButton
758 className={`p-1 text-foreground/90 transition-colors ${755 className="p-1 text-foreground/90 transition-colors hover:bg-accent"
759 hasReachedTerminalLimit
760 ? "cursor-not-allowed opacity-45 hover:bg-transparent"
761 : "hover:bg-accent"
762 }`}
763 onClick={onNewTerminalAction}756 onClick={onNewTerminalAction}
764 label={newTerminalActionLabel}757 label={newTerminalActionLabel}
765 >758 >
...@@ -839,7 +832,7 @@ export default function ThreadTerminalDrawer({...@@ -839,7 +832,7 @@ export default function ThreadTerminalDrawer({
839 <div className="inline-flex h-full items-stretch">832 <div className="inline-flex h-full items-stretch">
840 <TerminalActionButton833 <TerminalActionButton
841 className={`inline-flex h-full items-center px-1 text-foreground/90 transition-colors ${834 className={`inline-flex h-full items-center px-1 text-foreground/90 transition-colors ${
842 hasReachedTerminalLimit835 hasReachedSplitLimit
843 ? "cursor-not-allowed opacity-45 hover:bg-transparent"836 ? "cursor-not-allowed opacity-45 hover:bg-transparent"
844 : "hover:bg-accent/70"837 : "hover:bg-accent/70"
845 }`}838 }`}
...@@ -849,11 +842,7 @@ export default function ThreadTerminalDrawer({...@@ -849,11 +842,7 @@ export default function ThreadTerminalDrawer({
849 <SquareSplitHorizontal className="size-3.25" />842 <SquareSplitHorizontal className="size-3.25" />
850 </TerminalActionButton>843 </TerminalActionButton>
851 <TerminalActionButton844 <TerminalActionButton
852 className={`inline-flex h-full items-center border-l border-border/70 px-1 text-foreground/90 transition-colors ${845 className="inline-flex h-full items-center border-l border-border/70 px-1 text-foreground/90 transition-colors hover:bg-accent/70"
853 hasReachedTerminalLimit
854 ? "cursor-not-allowed opacity-45 hover:bg-transparent"
855 : "hover:bg-accent/70"
856 }`}
857 onClick={onNewTerminalAction}846 onClick={onNewTerminalAction}
858 label={newTerminalActionLabel}847 label={newTerminalActionLabel}
859 >848 >
apps/web/src/hooks/useHandleNewThread.ts+24-21
...@@ -1,32 +1,29 @@...@@ -1,32 +1,29 @@
1import { DEFAULT_RUNTIME_MODE, type ProjectId, ThreadId } from "@t3tools/contracts";1import { DEFAULT_RUNTIME_MODE, type ProjectId, ThreadId } from "@t3tools/contracts";
2import { useNavigate, useParams } from "@tanstack/react-router";2import { useNavigate, useParams } from "@tanstack/react-router";
3import { useCallback } from "react";3import { useCallback } from "react";
4import { type DraftThreadEnvMode, useComposerDraftStore } from "../composerDraftStore";4import {
5 type DraftThreadEnvMode,
6 type DraftThreadState,
7 useComposerDraftStore,
8} from "../composerDraftStore";
5import { newThreadId } from "../lib/utils";9import { newThreadId } from "../lib/utils";
6import { useStore } from "../store";10import { useStore } from "../store";
711
8export function useHandleNewThread() {12export function useHandleNewThread() {
9 const projects = useStore((store) => store.projects);13 const projects = useStore((store) => store.projects);
10 const threads = useStore((store) => store.threads);14 const threads = useStore((store) => store.threads);
11 const getDraftThreadByProjectId = useComposerDraftStore(
12 (store) => store.getDraftThreadByProjectId,
13 );
14 const getDraftThread = useComposerDraftStore((store) => store.getDraftThread);
15 const setProjectDraftThreadId = useComposerDraftStore((store) => store.setProjectDraftThreadId);
16 const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext);
17 const clearProjectDraftThreadId = useComposerDraftStore(
18 (store) => store.clearProjectDraftThreadId,
19 );
20 const navigate = useNavigate();15 const navigate = useNavigate();
21 const routeThreadId = useParams({16 const routeThreadId = useParams({
22 strict: false,17 strict: false,
23 select: (params) => (params.threadId ? ThreadId.makeUnsafe(params.threadId) : null),18 select: (params) => (params.threadId ? ThreadId.makeUnsafe(params.threadId) : null),
24 });19 });
20 const activeDraftThread = useComposerDraftStore((store) =>
21 routeThreadId ? (store.draftThreadsByThreadId[routeThreadId] ?? null) : null,
22 );
2523
26 const activeThread = routeThreadId24 const activeThread = routeThreadId
27 ? threads.find((thread) => thread.id === routeThreadId)25 ? threads.find((thread) => thread.id === routeThreadId)
28 : undefined;26 : undefined;
29 const activeDraftThread = routeThreadId ? getDraftThread(routeThreadId) : null;
3027
31 const handleNewThread = useCallback(28 const handleNewThread = useCallback(
32 (29 (
...@@ -37,10 +34,20 @@ export function useHandleNewThread() {...@@ -37,10 +34,20 @@ export function useHandleNewThread() {
37 envMode?: DraftThreadEnvMode;34 envMode?: DraftThreadEnvMode;
38 },35 },
39 ): Promise<void> => {36 ): Promise<void> => {
37 const {
38 clearProjectDraftThreadId,
39 getDraftThread,
40 getDraftThreadByProjectId,
41 setDraftThreadContext,
42 setProjectDraftThreadId,
43 } = useComposerDraftStore.getState();
40 const hasBranchOption = options?.branch !== undefined;44 const hasBranchOption = options?.branch !== undefined;
41 const hasWorktreePathOption = options?.worktreePath !== undefined;45 const hasWorktreePathOption = options?.worktreePath !== undefined;
42 const hasEnvModeOption = options?.envMode !== undefined;46 const hasEnvModeOption = options?.envMode !== undefined;
43 const storedDraftThread = getDraftThreadByProjectId(projectId);47 const storedDraftThread = getDraftThreadByProjectId(projectId);
48 const latestActiveDraftThread: DraftThreadState | null = routeThreadId
49 ? getDraftThread(routeThreadId)
50 : null;
44 if (storedDraftThread) {51 if (storedDraftThread) {
45 return (async () => {52 return (async () => {
46 if (hasBranchOption || hasWorktreePathOption || hasEnvModeOption) {53 if (hasBranchOption || hasWorktreePathOption || hasEnvModeOption) {
...@@ -63,7 +70,11 @@ export function useHandleNewThread() {...@@ -63,7 +70,11 @@ export function useHandleNewThread() {
6370
64 clearProjectDraftThreadId(projectId);71 clearProjectDraftThreadId(projectId);
6572
66 if (activeDraftThread && routeThreadId && activeDraftThread.projectId === projectId) {73 if (
74 latestActiveDraftThread &&
75 routeThreadId &&
76 latestActiveDraftThread.projectId === projectId
77 ) {
67 if (hasBranchOption || hasWorktreePathOption || hasEnvModeOption) {78 if (hasBranchOption || hasWorktreePathOption || hasEnvModeOption) {
68 setDraftThreadContext(routeThreadId, {79 setDraftThreadContext(routeThreadId, {
69 ...(hasBranchOption ? { branch: options?.branch ?? null } : {}),80 ...(hasBranchOption ? { branch: options?.branch ?? null } : {}),
...@@ -92,15 +103,7 @@ export function useHandleNewThread() {...@@ -92,15 +103,7 @@ export function useHandleNewThread() {
92 });103 });
93 })();104 })();
94 },105 },
95 [106 [navigate, routeThreadId],
96 activeDraftThread,
97 clearProjectDraftThreadId,
98 getDraftThreadByProjectId,
99 navigate,
100 routeThreadId,
101 setDraftThreadContext,
102 setProjectDraftThreadId,
103 ],
104 );107 );
105108
106 return {109 return {
apps/web/src/terminalStateStore.test.ts+49
...@@ -46,6 +46,28 @@ describe("terminalStateStore actions", () => {...@@ -46,6 +46,28 @@ describe("terminalStateStore actions", () => {
46 ]);46 ]);
47 });47 });
4848
49 it("caps splits at four terminals per group", () => {
50 const store = useTerminalStateStore.getState();
51 store.splitTerminal(THREAD_ID, "terminal-2");
52 store.splitTerminal(THREAD_ID, "terminal-3");
53 store.splitTerminal(THREAD_ID, "terminal-4");
54 store.splitTerminal(THREAD_ID, "terminal-5");
55
56 const terminalState = selectThreadTerminalState(
57 useTerminalStateStore.getState().terminalStateByThreadId,
58 THREAD_ID,
59 );
60 expect(terminalState.terminalIds).toEqual([
61 "default",
62 "terminal-2",
63 "terminal-3",
64 "terminal-4",
65 ]);
66 expect(terminalState.terminalGroups).toEqual([
67 { id: "group-default", terminalIds: ["default", "terminal-2", "terminal-3", "terminal-4"] },
68 ]);
69 });
70
49 it("creates new terminals in a separate group", () => {71 it("creates new terminals in a separate group", () => {
50 useTerminalStateStore.getState().newTerminal(THREAD_ID, "terminal-2");72 useTerminalStateStore.getState().newTerminal(THREAD_ID, "terminal-2");
5173
...@@ -62,6 +84,33 @@ describe("terminalStateStore actions", () => {...@@ -62,6 +84,33 @@ describe("terminalStateStore actions", () => {
62 ]);84 ]);
63 });85 });
6486
87 it("allows unlimited groups while keeping each group capped at four terminals", () => {
88 const store = useTerminalStateStore.getState();
89 store.splitTerminal(THREAD_ID, "terminal-2");
90 store.splitTerminal(THREAD_ID, "terminal-3");
91 store.splitTerminal(THREAD_ID, "terminal-4");
92 store.newTerminal(THREAD_ID, "terminal-5");
93 store.newTerminal(THREAD_ID, "terminal-6");
94
95 const terminalState = selectThreadTerminalState(
96 useTerminalStateStore.getState().terminalStateByThreadId,
97 THREAD_ID,
98 );
99 expect(terminalState.terminalIds).toEqual([
100 "default",
101 "terminal-2",
102 "terminal-3",
103 "terminal-4",
104 "terminal-5",
105 "terminal-6",
106 ]);
107 expect(terminalState.terminalGroups).toEqual([
108 { id: "group-default", terminalIds: ["default", "terminal-2", "terminal-3", "terminal-4"] },
109 { id: "group-terminal-5", terminalIds: ["terminal-5"] },
110 { id: "group-terminal-6", terminalIds: ["terminal-6"] },
111 ]);
112 });
113
65 it("tracks and clears terminal subprocess activity", () => {114 it("tracks and clears terminal subprocess activity", () => {
66 const store = useTerminalStateStore.getState();115 const store = useTerminalStateStore.getState();
67 store.splitTerminal(THREAD_ID, "terminal-2");116 store.splitTerminal(THREAD_ID, "terminal-2");
apps/web/src/terminalStateStore.ts+10-9
...@@ -11,7 +11,7 @@ import { createJSONStorage, persist } from "zustand/middleware";...@@ -11,7 +11,7 @@ import { createJSONStorage, persist } from "zustand/middleware";
11import {11import {
12 DEFAULT_THREAD_TERMINAL_HEIGHT,12 DEFAULT_THREAD_TERMINAL_HEIGHT,
13 DEFAULT_THREAD_TERMINAL_ID,13 DEFAULT_THREAD_TERMINAL_ID,
14 MAX_THREAD_TERMINAL_COUNT,14 MAX_TERMINALS_PER_GROUP,
15 type ThreadTerminalGroup,15 type ThreadTerminalGroup,
16} from "./types";16} from "./types";
1717
...@@ -28,10 +28,7 @@ interface ThreadTerminalState {...@@ -28,10 +28,7 @@ interface ThreadTerminalState {
28const TERMINAL_STATE_STORAGE_KEY = "t3code:terminal-state:v1";28const TERMINAL_STATE_STORAGE_KEY = "t3code:terminal-state:v1";
2929
30function normalizeTerminalIds(terminalIds: string[]): string[] {30function normalizeTerminalIds(terminalIds: string[]): string[] {
31 const ids = [...new Set(terminalIds.map((id) => id.trim()).filter((id) => id.length > 0))].slice(31 const ids = [...new Set(terminalIds.map((id) => id.trim()).filter((id) => id.length > 0))];
32 0,
33 MAX_THREAD_TERMINAL_COUNT,
34 );
35 return ids.length > 0 ? ids : [DEFAULT_THREAD_TERMINAL_ID];32 return ids.length > 0 ? ids : [DEFAULT_THREAD_TERMINAL_ID];
36}33}
3734
...@@ -243,10 +240,6 @@ function upsertTerminalIntoGroups(...@@ -243,10 +240,6 @@ function upsertTerminalIntoGroups(
243 }240 }
244241
245 const isNewTerminal = !normalized.terminalIds.includes(terminalId);242 const isNewTerminal = !normalized.terminalIds.includes(terminalId);
246 if (isNewTerminal && normalized.terminalIds.length >= MAX_THREAD_TERMINAL_COUNT) {
247 return normalized;
248 }
249
250 const terminalIds = isNewTerminal243 const terminalIds = isNewTerminal
251 ? [...normalized.terminalIds, terminalId]244 ? [...normalized.terminalIds, terminalId]
252 : normalized.terminalIds;245 : normalized.terminalIds;
...@@ -297,6 +290,14 @@ function upsertTerminalIntoGroups(...@@ -297,6 +290,14 @@ function upsertTerminalIntoGroups(
297 return normalized;290 return normalized;
298 }291 }
299292
293 if (
294 isNewTerminal &&
295 !destinationGroup.terminalIds.includes(terminalId) &&
296 destinationGroup.terminalIds.length >= MAX_TERMINALS_PER_GROUP
297 ) {
298 return normalized;
299 }
300
300 if (!destinationGroup.terminalIds.includes(terminalId)) {301 if (!destinationGroup.terminalIds.includes(terminalId)) {
301 const anchorIndex = destinationGroup.terminalIds.indexOf(normalized.activeTerminalId);302 const anchorIndex = destinationGroup.terminalIds.indexOf(normalized.activeTerminalId);
302 if (anchorIndex >= 0) {303 if (anchorIndex >= 0) {
apps/web/src/types.ts+1-1
...@@ -20,7 +20,7 @@ export const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access";...@@ -20,7 +20,7 @@ export const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access";
20export const DEFAULT_INTERACTION_MODE: ProviderInteractionMode = "default";20export const DEFAULT_INTERACTION_MODE: ProviderInteractionMode = "default";
21export const DEFAULT_THREAD_TERMINAL_HEIGHT = 280;21export const DEFAULT_THREAD_TERMINAL_HEIGHT = 280;
22export const DEFAULT_THREAD_TERMINAL_ID = "default";22export const DEFAULT_THREAD_TERMINAL_ID = "default";
23export const MAX_THREAD_TERMINAL_COUNT = 4;23export const MAX_TERMINALS_PER_GROUP = 4;
24export type ProjectScript = ContractProjectScript;24export type ProjectScript = ContractProjectScript;
2525
26export interface ThreadTerminalGroup {26export interface ThreadTerminalGroup {