| author | |
| committer | |
| log | c52ad29b31e890164d0e22443f36c1e39483d3bc |
| tree | 7338d975f87295e5297953304c179af735eb26b1 |
| parent | fcbf3f3c225ab41c44af3759ae27d8520ced61a4 |
- 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 tests10 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 | 20 | import { render } from "vitest-browser-react"; |
| 21 | 21 | |
| 22 | 22 | import { useComposerDraftStore } from "../composerDraftStore"; |
| 23 | import { isMacPlatform } from "../lib/utils"; | |
| 23 | 24 | import { getRouter } from "../router"; |
| 24 | 25 | import { useStore } from "../store"; |
| 25 | 26 | import { estimateTimelineMessageHeight } from "./timelineHeight"; |
| ... | ... | @@ -1062,6 +1063,130 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 1062 | 1063 | } |
| 1063 | 1064 | }); |
| 1064 | 1065 | |
| 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 | 1190 | it("keeps long proposed plans lightweight until the user expands them", async () => { |
| 1066 | 1191 | const mounted = await mountChatView({ |
| 1067 | 1192 | viewport: DEFAULT_VIEWPORT, |
apps/web/src/components/ChatView.tsx+16-8| ... | ... | @@ -76,7 +76,7 @@ import { |
| 76 | 76 | DEFAULT_INTERACTION_MODE, |
| 77 | 77 | DEFAULT_RUNTIME_MODE, |
| 78 | 78 | DEFAULT_THREAD_TERMINAL_ID, |
| 79 | MAX_THREAD_TERMINAL_COUNT, | |
| 79 | MAX_TERMINALS_PER_GROUP, | |
| 80 | 80 | type ChatMessage, |
| 81 | 81 | type TurnDiffSummary, |
| 82 | 82 | } from "../types"; |
| ... | ... | @@ -1037,7 +1037,16 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1037 | 1037 | (activeThread.messages.length > 0 || |
| 1038 | 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 | 1050 | const setThreadError = useCallback( |
| 1042 | 1051 | (targetThreadId: ThreadId | null, error: string | null) => { |
| 1043 | 1052 | if (!targetThreadId) return; |
| ... | ... | @@ -1085,17 +1094,17 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1085 | 1094 | setTerminalOpen(!terminalState.terminalOpen); |
| 1086 | 1095 | }, [activeThreadId, setTerminalOpen, terminalState.terminalOpen]); |
| 1087 | 1096 | const splitTerminal = useCallback(() => { |
| 1088 | if (!activeThreadId || hasReachedTerminalLimit) return; | |
| 1097 | if (!activeThreadId || hasReachedSplitLimit) return; | |
| 1089 | 1098 | const terminalId = `terminal-${randomUUID()}`; |
| 1090 | 1099 | storeSplitTerminal(activeThreadId, terminalId); |
| 1091 | 1100 | setTerminalFocusRequestId((value) => value + 1); |
| 1092 | }, [activeThreadId, storeSplitTerminal, hasReachedTerminalLimit]); | |
| 1101 | }, [activeThreadId, hasReachedSplitLimit, storeSplitTerminal]); | |
| 1093 | 1102 | const createNewTerminal = useCallback(() => { |
| 1094 | if (!activeThreadId || hasReachedTerminalLimit) return; | |
| 1103 | if (!activeThreadId) return; | |
| 1095 | 1104 | const terminalId = `terminal-${randomUUID()}`; |
| 1096 | 1105 | storeNewTerminal(activeThreadId, terminalId); |
| 1097 | 1106 | setTerminalFocusRequestId((value) => value + 1); |
| 1098 | }, [activeThreadId, storeNewTerminal, hasReachedTerminalLimit]); | |
| 1107 | }, [activeThreadId, storeNewTerminal]); | |
| 1099 | 1108 | const activateTerminal = useCallback( |
| 1100 | 1109 | (terminalId: string) => { |
| 1101 | 1110 | if (!activeThreadId) return; |
| ... | ... | @@ -1162,8 +1171,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1162 | 1171 | DEFAULT_THREAD_TERMINAL_ID; |
| 1163 | 1172 | const isBaseTerminalBusy = terminalState.runningTerminalIds.includes(baseTerminalId); |
| 1164 | 1173 | const wantsNewTerminal = Boolean(options?.preferNewTerminal) || isBaseTerminalBusy; |
| 1165 | const shouldCreateNewTerminal = | |
| 1166 | wantsNewTerminal && terminalState.terminalIds.length < MAX_THREAD_TERMINAL_COUNT; | |
| 1174 | const shouldCreateNewTerminal = wantsNewTerminal; | |
| 1167 | 1175 | const targetTerminalId = shouldCreateNewTerminal |
| 1168 | 1176 | ? `terminal-${randomUUID()}` |
| 1169 | 1177 | : baseTerminalId; |
apps/web/src/components/Sidebar.logic.test.ts+25| ... | ... | @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; |
| 2 | 2 | |
| 3 | 3 | import { |
| 4 | 4 | hasUnseenCompletion, |
| 5 | resolveThreadRowClassName, | |
| 5 | 6 | resolveThreadStatusPill, |
| 6 | 7 | shouldClearThreadSelectionOnMouseDown, |
| 7 | 8 | } from "./Sidebar.logic"; |
| ... | ... | @@ -154,3 +155,27 @@ describe("resolveThreadStatusPill", () => { |
| 154 | 155 | ).toMatchObject({ label: "Completed", pulse: false }); |
| 155 | 156 | }); |
| 156 | 157 | }); |
| 158 | ||
| 159 | describe("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 | 1 | import type { Thread } from "../types"; |
| 2 | import { cn } from "../lib/utils"; | |
| 2 | 3 | import { findLatestProposedPlan, isLatestTurnSettled } from "../session-logic"; |
| 3 | 4 | |
| 4 | 5 | export const THREAD_SELECTION_SAFE_SELECTOR = "[data-thread-item], [data-thread-selection-safe]"; |
| ... | ... | @@ -37,6 +38,37 @@ export function shouldClearThreadSelectionOnMouseDown(target: HTMLElement | null |
| 37 | 38 | return !target.closest(THREAD_SELECTION_SAFE_SELECTOR); |
| 38 | 39 | } |
| 39 | 40 | |
| 41 | export 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 | ||
| 40 | 72 | export function resolveThreadStatusPill(input: { |
| 41 | 73 | thread: ThreadStatusInput; |
| 42 | 74 | hasPendingApprovals: boolean; |
apps/web/src/components/Sidebar.tsx+10-9| ... | ... | @@ -83,7 +83,11 @@ import { |
| 83 | 83 | import { useThreadSelectionStore } from "../threadSelectionStore"; |
| 84 | 84 | import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; |
| 85 | 85 | import { isNonEmpty as isNonEmptyString } from "effect/String"; |
| 86 | import { resolveThreadStatusPill, shouldClearThreadSelectionOnMouseDown } from "./Sidebar.logic"; | |
| 86 | import { | |
| 87 | resolveThreadRowClassName, | |
| 88 | resolveThreadStatusPill, | |
| 89 | shouldClearThreadSelectionOnMouseDown, | |
| 90 | } from "./Sidebar.logic"; | |
| 87 | 91 | |
| 88 | 92 | const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; |
| 89 | 93 | const THREAD_PREVIEW_LIMIT = 6; |
| ... | ... | @@ -1397,13 +1401,10 @@ export default function Sidebar() { |
| 1397 | 1401 | render={<div role="button" tabIndex={0} />} |
| 1398 | 1402 | size="sm" |
| 1399 | 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 ${ | |
| 1401 | isSelected | |
| 1402 | ? "bg-primary/15 text-foreground dark:bg-primary/10" | |
| 1403 | : isActive | |
| 1404 | ? "bg-accent/85 text-foreground font-medium dark:bg-accent/55" | |
| 1405 | : "text-muted-foreground" | |
| 1406 | }`} | |
| 1404 | className={resolveThreadRowClassName({ | |
| 1405 | isActive, | |
| 1406 | isSelected, | |
| 1407 | })} | |
| 1407 | 1408 | onClick={(event) => { |
| 1408 | 1409 | handleThreadClick( |
| 1409 | 1410 | event, |
| ... | ... | @@ -1541,7 +1542,7 @@ export default function Sidebar() { |
| 1541 | 1542 | <span |
| 1542 | 1543 | className={`text-[10px] ${ |
| 1543 | 1544 | isHighlighted |
| 1544 | ? "text-foreground/65" | |
| 1545 | ? "text-foreground/72 dark:text-foreground/82" | |
| 1545 | 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 | 22 | import { |
| 23 | 23 | DEFAULT_THREAD_TERMINAL_HEIGHT, |
| 24 | 24 | DEFAULT_THREAD_TERMINAL_ID, |
| 25 | MAX_THREAD_TERMINAL_COUNT, | |
| 25 | MAX_TERMINALS_PER_GROUP, | |
| 26 | 26 | type ThreadTerminalGroup, |
| 27 | 27 | } from "../types"; |
| 28 | 28 | import { readNativeApi } from "~/nativeApi"; |
| ... | ... | @@ -605,7 +605,7 @@ export default function ThreadTerminalDrawer({ |
| 605 | 605 | const showGroupHeaders = |
| 606 | 606 | resolvedTerminalGroups.length > 1 || |
| 607 | 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 | 609 | const terminalLabelById = useMemo( |
| 610 | 610 | () => |
| 611 | 611 | new Map( |
| ... | ... | @@ -613,27 +613,24 @@ export default function ThreadTerminalDrawer({ |
| 613 | 613 | ), |
| 614 | 614 | [normalizedTerminalIds], |
| 615 | 615 | ); |
| 616 | const splitTerminalActionLabel = hasReachedTerminalLimit | |
| 617 | ? `Split Terminal (max ${MAX_THREAD_TERMINAL_COUNT})` | |
| 616 | const splitTerminalActionLabel = hasReachedSplitLimit | |
| 617 | ? `Split Terminal (max ${MAX_TERMINALS_PER_GROUP} per group)` | |
| 618 | 618 | : splitShortcutLabel |
| 619 | 619 | ? `Split Terminal (${splitShortcutLabel})` |
| 620 | 620 | : "Split Terminal"; |
| 621 | const newTerminalActionLabel = hasReachedTerminalLimit | |
| 622 | ? `New Terminal (max ${MAX_THREAD_TERMINAL_COUNT})` | |
| 623 | : newShortcutLabel | |
| 624 | ? `New Terminal (${newShortcutLabel})` | |
| 625 | : "New Terminal"; | |
| 621 | const newTerminalActionLabel = newShortcutLabel | |
| 622 | ? `New Terminal (${newShortcutLabel})` | |
| 623 | : "New Terminal"; | |
| 626 | 624 | const closeTerminalActionLabel = closeShortcutLabel |
| 627 | 625 | ? `Close Terminal (${closeShortcutLabel})` |
| 628 | 626 | : "Close Terminal"; |
| 629 | 627 | const onSplitTerminalAction = useCallback(() => { |
| 630 | if (hasReachedTerminalLimit) return; | |
| 628 | if (hasReachedSplitLimit) return; | |
| 631 | 629 | onSplitTerminal(); |
| 632 | }, [hasReachedTerminalLimit, onSplitTerminal]); | |
| 630 | }, [hasReachedSplitLimit, onSplitTerminal]); | |
| 633 | 631 | const onNewTerminalAction = useCallback(() => { |
| 634 | if (hasReachedTerminalLimit) return; | |
| 635 | 632 | onNewTerminal(); |
| 636 | }, [hasReachedTerminalLimit, onNewTerminal]); | |
| 633 | }, [onNewTerminal]); | |
| 637 | 634 | |
| 638 | 635 | useEffect(() => { |
| 639 | 636 | onHeightChangeRef.current = onHeightChange; |
| ... | ... | @@ -744,7 +741,7 @@ export default function ThreadTerminalDrawer({ |
| 744 | 741 | <div className="pointer-events-auto inline-flex items-center overflow-hidden rounded-md border border-border/80 bg-background/70"> |
| 745 | 742 | <TerminalActionButton |
| 746 | 743 | className={`p-1 text-foreground/90 transition-colors ${ |
| 747 | hasReachedTerminalLimit | |
| 744 | hasReachedSplitLimit | |
| 748 | 745 | ? "cursor-not-allowed opacity-45 hover:bg-transparent" |
| 749 | 746 | : "hover:bg-accent" |
| 750 | 747 | }`} |
| ... | ... | @@ -755,11 +752,7 @@ export default function ThreadTerminalDrawer({ |
| 755 | 752 | </TerminalActionButton> |
| 756 | 753 | <div className="h-4 w-px bg-border/80" /> |
| 757 | 754 | <TerminalActionButton |
| 758 | className={`p-1 text-foreground/90 transition-colors ${ | |
| 759 | hasReachedTerminalLimit | |
| 760 | ? "cursor-not-allowed opacity-45 hover:bg-transparent" | |
| 761 | : "hover:bg-accent" | |
| 762 | }`} | |
| 755 | className="p-1 text-foreground/90 transition-colors hover:bg-accent" | |
| 763 | 756 | onClick={onNewTerminalAction} |
| 764 | 757 | label={newTerminalActionLabel} |
| 765 | 758 | > |
| ... | ... | @@ -839,7 +832,7 @@ export default function ThreadTerminalDrawer({ |
| 839 | 832 | <div className="inline-flex h-full items-stretch"> |
| 840 | 833 | <TerminalActionButton |
| 841 | 834 | className={`inline-flex h-full items-center px-1 text-foreground/90 transition-colors ${ |
| 842 | hasReachedTerminalLimit | |
| 835 | hasReachedSplitLimit | |
| 843 | 836 | ? "cursor-not-allowed opacity-45 hover:bg-transparent" |
| 844 | 837 | : "hover:bg-accent/70" |
| 845 | 838 | }`} |
| ... | ... | @@ -849,11 +842,7 @@ export default function ThreadTerminalDrawer({ |
| 849 | 842 | <SquareSplitHorizontal className="size-3.25" /> |
| 850 | 843 | </TerminalActionButton> |
| 851 | 844 | <TerminalActionButton |
| 852 | className={`inline-flex h-full items-center border-l border-border/70 px-1 text-foreground/90 transition-colors ${ | |
| 853 | hasReachedTerminalLimit | |
| 854 | ? "cursor-not-allowed opacity-45 hover:bg-transparent" | |
| 855 | : "hover:bg-accent/70" | |
| 856 | }`} | |
| 845 | className="inline-flex h-full items-center border-l border-border/70 px-1 text-foreground/90 transition-colors hover:bg-accent/70" | |
| 857 | 846 | onClick={onNewTerminalAction} |
| 858 | 847 | label={newTerminalActionLabel} |
| 859 | 848 | > |
apps/web/src/hooks/useHandleNewThread.ts+24-21| ... | ... | @@ -1,32 +1,29 @@ |
| 1 | 1 | import { DEFAULT_RUNTIME_MODE, type ProjectId, ThreadId } from "@t3tools/contracts"; |
| 2 | 2 | import { useNavigate, useParams } from "@tanstack/react-router"; |
| 3 | 3 | import { useCallback } from "react"; |
| 4 | import { type DraftThreadEnvMode, useComposerDraftStore } from "../composerDraftStore"; | |
| 4 | import { | |
| 5 | type DraftThreadEnvMode, | |
| 6 | type DraftThreadState, | |
| 7 | useComposerDraftStore, | |
| 8 | } from "../composerDraftStore"; | |
| 5 | 9 | import { newThreadId } from "../lib/utils"; |
| 6 | 10 | import { useStore } from "../store"; |
| 7 | 11 | |
| 8 | 12 | export function useHandleNewThread() { |
| 9 | 13 | const projects = useStore((store) => store.projects); |
| 10 | 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 | 15 | const navigate = useNavigate(); |
| 21 | 16 | const routeThreadId = useParams({ |
| 22 | 17 | strict: false, |
| 23 | 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 | ); | |
| 25 | 23 | |
| 26 | 24 | const activeThread = routeThreadId |
| 27 | 25 | ? threads.find((thread) => thread.id === routeThreadId) |
| 28 | 26 | : undefined; |
| 29 | const activeDraftThread = routeThreadId ? getDraftThread(routeThreadId) : null; | |
| 30 | 27 | |
| 31 | 28 | const handleNewThread = useCallback( |
| 32 | 29 | ( |
| ... | ... | @@ -37,10 +34,20 @@ export function useHandleNewThread() { |
| 37 | 34 | envMode?: DraftThreadEnvMode; |
| 38 | 35 | }, |
| 39 | 36 | ): Promise<void> => { |
| 37 | const { | |
| 38 | clearProjectDraftThreadId, | |
| 39 | getDraftThread, | |
| 40 | getDraftThreadByProjectId, | |
| 41 | setDraftThreadContext, | |
| 42 | setProjectDraftThreadId, | |
| 43 | } = useComposerDraftStore.getState(); | |
| 40 | 44 | const hasBranchOption = options?.branch !== undefined; |
| 41 | 45 | const hasWorktreePathOption = options?.worktreePath !== undefined; |
| 42 | 46 | const hasEnvModeOption = options?.envMode !== undefined; |
| 43 | 47 | const storedDraftThread = getDraftThreadByProjectId(projectId); |
| 48 | const latestActiveDraftThread: DraftThreadState | null = routeThreadId | |
| 49 | ? getDraftThread(routeThreadId) | |
| 50 | : null; | |
| 44 | 51 | if (storedDraftThread) { |
| 45 | 52 | return (async () => { |
| 46 | 53 | if (hasBranchOption || hasWorktreePathOption || hasEnvModeOption) { |
| ... | ... | @@ -63,7 +70,11 @@ export function useHandleNewThread() { |
| 63 | 70 | |
| 64 | 71 | clearProjectDraftThreadId(projectId); |
| 65 | 72 | |
| 66 | if (activeDraftThread && routeThreadId && activeDraftThread.projectId === projectId) { | |
| 73 | if ( | |
| 74 | latestActiveDraftThread && | |
| 75 | routeThreadId && | |
| 76 | latestActiveDraftThread.projectId === projectId | |
| 77 | ) { | |
| 67 | 78 | if (hasBranchOption || hasWorktreePathOption || hasEnvModeOption) { |
| 68 | 79 | setDraftThreadContext(routeThreadId, { |
| 69 | 80 | ...(hasBranchOption ? { branch: options?.branch ?? null } : {}), |
| ... | ... | @@ -92,15 +103,7 @@ export function useHandleNewThread() { |
| 92 | 103 | }); |
| 93 | 104 | })(); |
| 94 | 105 | }, |
| 95 | [ | |
| 96 | activeDraftThread, | |
| 97 | clearProjectDraftThreadId, | |
| 98 | getDraftThreadByProjectId, | |
| 99 | navigate, | |
| 100 | routeThreadId, | |
| 101 | setDraftThreadContext, | |
| 102 | setProjectDraftThreadId, | |
| 103 | ], | |
| 106 | [navigate, routeThreadId], | |
| 104 | 107 | ); |
| 105 | 108 | |
| 106 | 109 | return { |
apps/web/src/terminalStateStore.test.ts+49| ... | ... | @@ -46,6 +46,28 @@ describe("terminalStateStore actions", () => { |
| 46 | 46 | ]); |
| 47 | 47 | }); |
| 48 | 48 | |
| 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 | 71 | it("creates new terminals in a separate group", () => { |
| 50 | 72 | useTerminalStateStore.getState().newTerminal(THREAD_ID, "terminal-2"); |
| 51 | 73 | |
| ... | ... | @@ -62,6 +84,33 @@ describe("terminalStateStore actions", () => { |
| 62 | 84 | ]); |
| 63 | 85 | }); |
| 64 | 86 | |
| 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 | 114 | it("tracks and clears terminal subprocess activity", () => { |
| 66 | 115 | const store = useTerminalStateStore.getState(); |
| 67 | 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 | 11 | import { |
| 12 | 12 | DEFAULT_THREAD_TERMINAL_HEIGHT, |
| 13 | 13 | DEFAULT_THREAD_TERMINAL_ID, |
| 14 | MAX_THREAD_TERMINAL_COUNT, | |
| 14 | MAX_TERMINALS_PER_GROUP, | |
| 15 | 15 | type ThreadTerminalGroup, |
| 16 | 16 | } from "./types"; |
| 17 | 17 | |
| ... | ... | @@ -28,10 +28,7 @@ interface ThreadTerminalState { |
| 28 | 28 | const TERMINAL_STATE_STORAGE_KEY = "t3code:terminal-state:v1"; |
| 29 | 29 | |
| 30 | 30 | function normalizeTerminalIds(terminalIds: string[]): string[] { |
| 31 | const ids = [...new Set(terminalIds.map((id) => id.trim()).filter((id) => id.length > 0))].slice( | |
| 32 | 0, | |
| 33 | MAX_THREAD_TERMINAL_COUNT, | |
| 34 | ); | |
| 31 | const ids = [...new Set(terminalIds.map((id) => id.trim()).filter((id) => id.length > 0))]; | |
| 35 | 32 | return ids.length > 0 ? ids : [DEFAULT_THREAD_TERMINAL_ID]; |
| 36 | 33 | } |
| 37 | 34 | |
| ... | ... | @@ -243,10 +240,6 @@ function upsertTerminalIntoGroups( |
| 243 | 240 | } |
| 244 | 241 | |
| 245 | 242 | const isNewTerminal = !normalized.terminalIds.includes(terminalId); |
| 246 | if (isNewTerminal && normalized.terminalIds.length >= MAX_THREAD_TERMINAL_COUNT) { | |
| 247 | return normalized; | |
| 248 | } | |
| 249 | ||
| 250 | 243 | const terminalIds = isNewTerminal |
| 251 | 244 | ? [...normalized.terminalIds, terminalId] |
| 252 | 245 | : normalized.terminalIds; |
| ... | ... | @@ -297,6 +290,14 @@ function upsertTerminalIntoGroups( |
| 297 | 290 | return normalized; |
| 298 | 291 | } |
| 299 | 292 | |
| 293 | if ( | |
| 294 | isNewTerminal && | |
| 295 | !destinationGroup.terminalIds.includes(terminalId) && | |
| 296 | destinationGroup.terminalIds.length >= MAX_TERMINALS_PER_GROUP | |
| 297 | ) { | |
| 298 | return normalized; | |
| 299 | } | |
| 300 | ||
| 300 | 301 | if (!destinationGroup.terminalIds.includes(terminalId)) { |
| 301 | 302 | const anchorIndex = destinationGroup.terminalIds.indexOf(normalized.activeTerminalId); |
| 302 | 303 | if (anchorIndex >= 0) { |
apps/web/src/types.ts+1-1| ... | ... | @@ -20,7 +20,7 @@ export const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access"; |
| 20 | 20 | export const DEFAULT_INTERACTION_MODE: ProviderInteractionMode = "default"; |
| 21 | 21 | export const DEFAULT_THREAD_TERMINAL_HEIGHT = 280; |
| 22 | 22 | export const DEFAULT_THREAD_TERMINAL_ID = "default"; |
| 23 | export const MAX_THREAD_TERMINAL_COUNT = 4; | |
| 23 | export const MAX_TERMINALS_PER_GROUP = 4; | |
| 24 | 24 | export type ProjectScript = ContractProjectScript; |
| 25 | 25 | |
| 26 | 26 | export interface ThreadTerminalGroup { |