| author | |
| committer | |
| log | 1371ce2ccef7342399a6a020e69073334cfa364b |
| tree | 9806158223236d57be1b76a2a94d5595fd79786e |
| parent | d6408a29494c27d55a71fbb62f9ea9a8c6241786 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Cursor Agent <cursoragent@cursor.com>10 files changed, 325 insertions(+), 82 deletions(-)
.gitignore+2-1| ... | ... | @@ -18,4 +18,5 @@ apps/web/.playwright |
| 18 | 18 | apps/web/playwright-report |
| 19 | 19 | apps/web/src/components/__screenshots__ |
| 20 | 20 | .vitest-* |
| 21 | __screenshots__/ | |
| \ No newline at end of file | ||
| 21 | __screenshots__/ | |
| 22 | .tanstack |
apps/web/src/components/ChatView.browser.tsx+219-37| ... | ... | @@ -24,6 +24,7 @@ import { useComposerDraftStore } from "../composerDraftStore"; |
| 24 | 24 | import { |
| 25 | 25 | INLINE_TERMINAL_CONTEXT_PLACEHOLDER, |
| 26 | 26 | type TerminalContextDraft, |
| 27 | removeInlineTerminalContextPlaceholder, | |
| 27 | 28 | } from "../lib/terminalContext"; |
| 28 | 29 | import { isMacPlatform } from "../lib/utils"; |
| 29 | 30 | import { getRouter } from "../router"; |
| ... | ... | @@ -324,6 +325,18 @@ function createDraftOnlySnapshot(): OrchestrationReadModel { |
| 324 | 325 | }; |
| 325 | 326 | } |
| 326 | 327 | |
| 328 | function withProjectScripts( | |
| 329 | snapshot: OrchestrationReadModel, | |
| 330 | scripts: OrchestrationReadModel["projects"][number]["scripts"], | |
| 331 | ): OrchestrationReadModel { | |
| 332 | return { | |
| 333 | ...snapshot, | |
| 334 | projects: snapshot.projects.map((project) => | |
| 335 | project.id === PROJECT_ID ? { ...project, scripts: Array.from(scripts) } : project, | |
| 336 | ), | |
| 337 | }; | |
| 338 | } | |
| 339 | ||
| 327 | 340 | function createSnapshotWithLongProposedPlan(): OrchestrationReadModel { |
| 328 | 341 | const snapshot = createSnapshotForTargetUser({ |
| 329 | 342 | targetMessageId: "msg-user-plan-target" as MessageId, |
| ... | ... | @@ -575,6 +588,58 @@ async function waitForInteractionModeButton( |
| 575 | 588 | ); |
| 576 | 589 | } |
| 577 | 590 | |
| 591 | async function waitForServerConfigToApply(): Promise<void> { | |
| 592 | await vi.waitFor( | |
| 593 | () => { | |
| 594 | expect(wsRequests.some((request) => request._tag === WS_METHODS.serverGetConfig)).toBe(true); | |
| 595 | }, | |
| 596 | { timeout: 8_000, interval: 16 }, | |
| 597 | ); | |
| 598 | await waitForLayout(); | |
| 599 | } | |
| 600 | ||
| 601 | function dispatchChatNewShortcut(): void { | |
| 602 | const useMetaForMod = isMacPlatform(navigator.platform); | |
| 603 | window.dispatchEvent( | |
| 604 | new KeyboardEvent("keydown", { | |
| 605 | key: "o", | |
| 606 | shiftKey: true, | |
| 607 | metaKey: useMetaForMod, | |
| 608 | ctrlKey: !useMetaForMod, | |
| 609 | bubbles: true, | |
| 610 | cancelable: true, | |
| 611 | }), | |
| 612 | ); | |
| 613 | } | |
| 614 | ||
| 615 | async function triggerChatNewShortcutUntilPath( | |
| 616 | router: ReturnType<typeof getRouter>, | |
| 617 | predicate: (pathname: string) => boolean, | |
| 618 | errorMessage: string, | |
| 619 | ): Promise<string> { | |
| 620 | let pathname = router.state.location.pathname; | |
| 621 | const deadline = Date.now() + 8_000; | |
| 622 | while (Date.now() < deadline) { | |
| 623 | dispatchChatNewShortcut(); | |
| 624 | await waitForLayout(); | |
| 625 | pathname = router.state.location.pathname; | |
| 626 | if (predicate(pathname)) { | |
| 627 | return pathname; | |
| 628 | } | |
| 629 | } | |
| 630 | throw new Error(`${errorMessage} Last path: ${pathname}`); | |
| 631 | } | |
| 632 | ||
| 633 | async function waitForNewThreadShortcutLabel(): Promise<void> { | |
| 634 | const newThreadButton = page.getByTestId("new-thread-button"); | |
| 635 | await expect.element(newThreadButton).toBeInTheDocument(); | |
| 636 | await newThreadButton.hover(); | |
| 637 | const shortcutLabel = isMacPlatform(navigator.platform) | |
| 638 | ? "New thread (⇧⌘O)" | |
| 639 | : "New thread (Ctrl+Shift+O)"; | |
| 640 | await expect.element(page.getByText(shortcutLabel)).toBeInTheDocument(); | |
| 641 | } | |
| 642 | ||
| 578 | 643 | async function waitForImagesToLoad(scope: ParentNode): Promise<void> { |
| 579 | 644 | const images = Array.from(scope.querySelectorAll("img")); |
| 580 | 645 | if (images.length === 0) { |
| ... | ... | @@ -980,6 +1045,145 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 980 | 1045 | } |
| 981 | 1046 | }); |
| 982 | 1047 | |
| 1048 | it("runs project scripts from local draft threads at the project cwd", async () => { | |
| 1049 | useComposerDraftStore.setState({ | |
| 1050 | draftThreadsByThreadId: { | |
| 1051 | [THREAD_ID]: { | |
| 1052 | projectId: PROJECT_ID, | |
| 1053 | createdAt: NOW_ISO, | |
| 1054 | runtimeMode: "full-access", | |
| 1055 | interactionMode: "default", | |
| 1056 | branch: null, | |
| 1057 | worktreePath: null, | |
| 1058 | envMode: "local", | |
| 1059 | }, | |
| 1060 | }, | |
| 1061 | projectDraftThreadIdByProjectId: { | |
| 1062 | [PROJECT_ID]: THREAD_ID, | |
| 1063 | }, | |
| 1064 | }); | |
| 1065 | ||
| 1066 | const mounted = await mountChatView({ | |
| 1067 | viewport: DEFAULT_VIEWPORT, | |
| 1068 | snapshot: withProjectScripts(createDraftOnlySnapshot(), [ | |
| 1069 | { | |
| 1070 | id: "lint", | |
| 1071 | name: "Lint", | |
| 1072 | command: "bun run lint", | |
| 1073 | icon: "lint", | |
| 1074 | runOnWorktreeCreate: false, | |
| 1075 | }, | |
| 1076 | ]), | |
| 1077 | }); | |
| 1078 | ||
| 1079 | try { | |
| 1080 | const runButton = await waitForElement( | |
| 1081 | () => | |
| 1082 | Array.from(document.querySelectorAll("button")).find( | |
| 1083 | (button) => button.title === "Run Lint", | |
| 1084 | ) as HTMLButtonElement | null, | |
| 1085 | "Unable to find Run Lint button.", | |
| 1086 | ); | |
| 1087 | runButton.click(); | |
| 1088 | ||
| 1089 | await vi.waitFor( | |
| 1090 | () => { | |
| 1091 | const openRequest = wsRequests.find( | |
| 1092 | (request) => request._tag === WS_METHODS.terminalOpen, | |
| 1093 | ); | |
| 1094 | expect(openRequest).toMatchObject({ | |
| 1095 | _tag: WS_METHODS.terminalOpen, | |
| 1096 | threadId: THREAD_ID, | |
| 1097 | cwd: "/repo/project", | |
| 1098 | env: { | |
| 1099 | T3CODE_PROJECT_ROOT: "/repo/project", | |
| 1100 | }, | |
| 1101 | }); | |
| 1102 | }, | |
| 1103 | { timeout: 8_000, interval: 16 }, | |
| 1104 | ); | |
| 1105 | ||
| 1106 | await vi.waitFor( | |
| 1107 | () => { | |
| 1108 | const writeRequest = wsRequests.find( | |
| 1109 | (request) => request._tag === WS_METHODS.terminalWrite, | |
| 1110 | ); | |
| 1111 | expect(writeRequest).toMatchObject({ | |
| 1112 | _tag: WS_METHODS.terminalWrite, | |
| 1113 | threadId: THREAD_ID, | |
| 1114 | data: "bun run lint\r", | |
| 1115 | }); | |
| 1116 | }, | |
| 1117 | { timeout: 8_000, interval: 16 }, | |
| 1118 | ); | |
| 1119 | } finally { | |
| 1120 | await mounted.cleanup(); | |
| 1121 | } | |
| 1122 | }); | |
| 1123 | ||
| 1124 | it("runs project scripts from worktree draft threads at the worktree cwd", async () => { | |
| 1125 | useComposerDraftStore.setState({ | |
| 1126 | draftThreadsByThreadId: { | |
| 1127 | [THREAD_ID]: { | |
| 1128 | projectId: PROJECT_ID, | |
| 1129 | createdAt: NOW_ISO, | |
| 1130 | runtimeMode: "full-access", | |
| 1131 | interactionMode: "default", | |
| 1132 | branch: "feature/draft", | |
| 1133 | worktreePath: "/repo/worktrees/feature-draft", | |
| 1134 | envMode: "worktree", | |
| 1135 | }, | |
| 1136 | }, | |
| 1137 | projectDraftThreadIdByProjectId: { | |
| 1138 | [PROJECT_ID]: THREAD_ID, | |
| 1139 | }, | |
| 1140 | }); | |
| 1141 | ||
| 1142 | const mounted = await mountChatView({ | |
| 1143 | viewport: DEFAULT_VIEWPORT, | |
| 1144 | snapshot: withProjectScripts(createDraftOnlySnapshot(), [ | |
| 1145 | { | |
| 1146 | id: "test", | |
| 1147 | name: "Test", | |
| 1148 | command: "bun run test", | |
| 1149 | icon: "test", | |
| 1150 | runOnWorktreeCreate: false, | |
| 1151 | }, | |
| 1152 | ]), | |
| 1153 | }); | |
| 1154 | ||
| 1155 | try { | |
| 1156 | const runButton = await waitForElement( | |
| 1157 | () => | |
| 1158 | Array.from(document.querySelectorAll("button")).find( | |
| 1159 | (button) => button.title === "Run Test", | |
| 1160 | ) as HTMLButtonElement | null, | |
| 1161 | "Unable to find Run Test button.", | |
| 1162 | ); | |
| 1163 | runButton.click(); | |
| 1164 | ||
| 1165 | await vi.waitFor( | |
| 1166 | () => { | |
| 1167 | const openRequest = wsRequests.find( | |
| 1168 | (request) => request._tag === WS_METHODS.terminalOpen, | |
| 1169 | ); | |
| 1170 | expect(openRequest).toMatchObject({ | |
| 1171 | _tag: WS_METHODS.terminalOpen, | |
| 1172 | threadId: THREAD_ID, | |
| 1173 | cwd: "/repo/worktrees/feature-draft", | |
| 1174 | env: { | |
| 1175 | T3CODE_PROJECT_ROOT: "/repo/project", | |
| 1176 | T3CODE_WORKTREE_PATH: "/repo/worktrees/feature-draft", | |
| 1177 | }, | |
| 1178 | }); | |
| 1179 | }, | |
| 1180 | { timeout: 8_000, interval: 16 }, | |
| 1181 | ); | |
| 1182 | } finally { | |
| 1183 | await mounted.cleanup(); | |
| 1184 | } | |
| 1185 | }); | |
| 1186 | ||
| 983 | 1187 | it("toggles plan mode with Shift+Tab only while the composer is focused", async () => { |
| 984 | 1188 | const mounted = await mountChatView({ |
| 985 | 1189 | viewport: DEFAULT_VIEWPORT, |
| ... | ... | @@ -1045,7 +1249,7 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 1045 | 1249 | } |
| 1046 | 1250 | }); |
| 1047 | 1251 | |
| 1048 | it("keeps backspaced terminal context pills removed when a new one is added", async () => { | |
| 1252 | it("keeps removed terminal context pills removed when a new one is added", async () => { | |
| 1049 | 1253 | const removedLabel = "Terminal 1 lines 1-2"; |
| 1050 | 1254 | const addedLabel = "Terminal 2 lines 9-10"; |
| 1051 | 1255 | useComposerDraftStore.getState().addTerminalContext( |
| ... | ... | @@ -1075,15 +1279,11 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 1075 | 1279 | { timeout: 8_000, interval: 16 }, |
| 1076 | 1280 | ); |
| 1077 | 1281 | |
| 1078 | const composerEditor = await waitForComposerEditor(); | |
| 1079 | composerEditor.focus(); | |
| 1080 | composerEditor.dispatchEvent( | |
| 1081 | new KeyboardEvent("keydown", { | |
| 1082 | key: "Backspace", | |
| 1083 | bubbles: true, | |
| 1084 | cancelable: true, | |
| 1085 | }), | |
| 1086 | ); | |
| 1282 | const store = useComposerDraftStore.getState(); | |
| 1283 | const currentPrompt = store.draftsByThreadId[THREAD_ID]?.prompt ?? ""; | |
| 1284 | const nextPrompt = removeInlineTerminalContextPlaceholder(currentPrompt, 0); | |
| 1285 | store.setPrompt(THREAD_ID, nextPrompt.prompt); | |
| 1286 | store.removeTerminalContext(THREAD_ID, "ctx-removed"); | |
| 1087 | 1287 | |
| 1088 | 1288 | await vi.waitFor( |
| 1089 | 1289 | () => { |
| ... | ... | @@ -1505,19 +1705,12 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 1505 | 1705 | }); |
| 1506 | 1706 | |
| 1507 | 1707 | try { |
| 1508 | const useMetaForMod = isMacPlatform(navigator.platform); | |
| 1509 | window.dispatchEvent( | |
| 1510 | new KeyboardEvent("keydown", { | |
| 1511 | key: "o", | |
| 1512 | shiftKey: true, | |
| 1513 | metaKey: useMetaForMod, | |
| 1514 | ctrlKey: !useMetaForMod, | |
| 1515 | bubbles: true, | |
| 1516 | cancelable: true, | |
| 1517 | }), | |
| 1518 | ); | |
| 1519 | ||
| 1520 | await waitForURL( | |
| 1708 | await waitForNewThreadShortcutLabel(); | |
| 1709 | await waitForServerConfigToApply(); | |
| 1710 | const composerEditor = await waitForComposerEditor(); | |
| 1711 | composerEditor.focus(); | |
| 1712 | await waitForLayout(); | |
| 1713 | await triggerChatNewShortcutUntilPath( | |
| 1521 | 1714 | mounted.router, |
| 1522 | 1715 | (path) => UUID_ROUTE_RE.test(path), |
| 1523 | 1716 | "Route should have changed to a new draft thread UUID from the shortcut.", |
| ... | ... | @@ -1526,7 +1719,6 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 1526 | 1719 | await mounted.cleanup(); |
| 1527 | 1720 | } |
| 1528 | 1721 | }); |
| 1529 | ||
| 1530 | 1722 | it("creates a fresh draft after the previous draft thread is promoted", async () => { |
| 1531 | 1723 | const mounted = await mountChatView({ |
| 1532 | 1724 | viewport: DEFAULT_VIEWPORT, |
| ... | ... | @@ -1561,6 +1753,8 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 1561 | 1753 | try { |
| 1562 | 1754 | const newThreadButton = page.getByTestId("new-thread-button"); |
| 1563 | 1755 | await expect.element(newThreadButton).toBeInTheDocument(); |
| 1756 | await waitForNewThreadShortcutLabel(); | |
| 1757 | await waitForServerConfigToApply(); | |
| 1564 | 1758 | await newThreadButton.click(); |
| 1565 | 1759 | |
| 1566 | 1760 | const promotedThreadPath = await waitForURL( |
| ... | ... | @@ -1574,19 +1768,7 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 1574 | 1768 | syncServerReadModel(addThreadToSnapshot(fixture.snapshot, promotedThreadId)); |
| 1575 | 1769 | useComposerDraftStore.getState().clearDraftThread(promotedThreadId); |
| 1576 | 1770 | |
| 1577 | const useMetaForMod = isMacPlatform(navigator.platform); | |
| 1578 | window.dispatchEvent( | |
| 1579 | new KeyboardEvent("keydown", { | |
| 1580 | key: "o", | |
| 1581 | shiftKey: true, | |
| 1582 | metaKey: useMetaForMod, | |
| 1583 | ctrlKey: !useMetaForMod, | |
| 1584 | bubbles: true, | |
| 1585 | cancelable: true, | |
| 1586 | }), | |
| 1587 | ); | |
| 1588 | ||
| 1589 | const freshThreadPath = await waitForURL( | |
| 1771 | const freshThreadPath = await triggerChatNewShortcutUntilPath( | |
| 1590 | 1772 | mounted.router, |
| 1591 | 1773 | (path) => UUID_ROUTE_RE.test(path) && path !== promotedThreadPath, |
| 1592 | 1774 | "Shortcut should create a fresh draft instead of reusing the promoted thread.", |
apps/web/src/components/ChatView.tsx+8-6| ... | ... | @@ -112,6 +112,7 @@ import { type NewProjectScriptInput } from "./ProjectScriptsControl"; |
| 112 | 112 | import { |
| 113 | 113 | commandForProjectScript, |
| 114 | 114 | nextProjectScriptId, |
| 115 | projectScriptCwd, | |
| 115 | 116 | projectScriptRuntimeEnv, |
| 116 | 117 | projectScriptIdFromCommand, |
| 117 | 118 | setupProjectScript, |
| ... | ... | @@ -994,7 +995,12 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 994 | 995 | latestTurnSettled, |
| 995 | 996 | timelineEntries, |
| 996 | 997 | ]); |
| 997 | const gitCwd = activeThread?.worktreePath ?? activeProject?.cwd ?? null; | |
| 998 | const gitCwd = activeProject | |
| 999 | ? projectScriptCwd({ | |
| 1000 | project: { cwd: activeProject.cwd }, | |
| 1001 | worktreePath: activeThread?.worktreePath ?? null, | |
| 1002 | }) | |
| 1003 | : null; | |
| 998 | 1004 | const composerTriggerKind = composerTrigger?.kind ?? null; |
| 999 | 1005 | const pathTriggerQuery = composerTrigger?.kind === "path" ? composerTrigger.query : ""; |
| 1000 | 1006 | const isPathTrigger = composerTriggerKind === "path"; |
| ... | ... | @@ -1303,12 +1309,10 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1303 | 1309 | worktreePath?: string | null; |
| 1304 | 1310 | preferNewTerminal?: boolean; |
| 1305 | 1311 | rememberAsLastInvoked?: boolean; |
| 1306 | allowLocalDraftThread?: boolean; | |
| 1307 | 1312 | }, |
| 1308 | 1313 | ) => { |
| 1309 | 1314 | const api = readNativeApi(); |
| 1310 | 1315 | if (!api || !activeThreadId || !activeProject || !activeThread) return; |
| 1311 | if (!isServerThread && !options?.allowLocalDraftThread) return; | |
| 1312 | 1316 | if (options?.rememberAsLastInvoked !== false) { |
| 1313 | 1317 | setLastInvokedScriptByProjectId((current) => { |
| 1314 | 1318 | if (current[activeProject.id] === script.id) return current; |
| ... | ... | @@ -1377,7 +1381,6 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1377 | 1381 | activeThread, |
| 1378 | 1382 | activeThreadId, |
| 1379 | 1383 | gitCwd, |
| 1380 | isServerThread, | |
| 1381 | 1384 | setTerminalOpen, |
| 1382 | 1385 | setThreadError, |
| 1383 | 1386 | storeNewTerminal, |
| ... | ... | @@ -2566,7 +2569,6 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2566 | 2569 | const setupScriptOptions: Parameters<typeof runProjectScript>[1] = { |
| 2567 | 2570 | worktreePath: nextThreadWorktreePath, |
| 2568 | 2571 | rememberAsLastInvoked: false, |
| 2569 | allowLocalDraftThread: createdServerThreadForLocalDraft, | |
| 2570 | 2572 | }; |
| 2571 | 2573 | if (nextThreadWorktreePath) { |
| 2572 | 2574 | setupScriptOptions.cwd = nextThreadWorktreePath; |
| ... | ... | @@ -3465,7 +3467,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 3465 | 3467 | activeThreadTitle={activeThread.title} |
| 3466 | 3468 | activeProjectName={activeProject?.name} |
| 3467 | 3469 | isGitRepo={isGitRepo} |
| 3468 | openInCwd={activeThread.worktreePath ?? activeProject?.cwd ?? null} | |
| 3470 | openInCwd={gitCwd} | |
| 3469 | 3471 | activeProjectScripts={activeProject?.scripts} |
| 3470 | 3472 | preferredScriptId={ |
| 3471 | 3473 | activeProject ? (lastInvokedScriptByProjectId[activeProject.id] ?? null) : null |
apps/web/src/components/Sidebar.tsx+1-1| ... | ... | @@ -258,7 +258,7 @@ export default function Sidebar() { |
| 258 | 258 | const markThreadUnread = useStore((store) => store.markThreadUnread); |
| 259 | 259 | const toggleProject = useStore((store) => store.toggleProject); |
| 260 | 260 | const reorderProjects = useStore((store) => store.reorderProjects); |
| 261 | const clearComposerDraftForThread = useComposerDraftStore((store) => store.clearThreadDraft); | |
| 261 | const clearComposerDraftForThread = useComposerDraftStore((store) => store.clearDraftThread); | |
| 262 | 262 | const getDraftThreadByProjectId = useComposerDraftStore( |
| 263 | 263 | (store) => store.getDraftThreadByProjectId, |
| 264 | 264 | ); |
apps/web/src/composerDraftStore.test.ts+2| ... | ... | @@ -517,9 +517,11 @@ describe("composerDraftStore project draft thread mapping", () => { |
| 517 | 517 | it("clears draft registration independently", () => { |
| 518 | 518 | const store = useComposerDraftStore.getState(); |
| 519 | 519 | store.setProjectDraftThreadId(projectId, threadId); |
| 520 | store.setPrompt(threadId, "remove me"); | |
| 520 | 521 | store.clearDraftThread(threadId); |
| 521 | 522 | expect(useComposerDraftStore.getState().getDraftThreadByProjectId(projectId)).toBeNull(); |
| 522 | 523 | expect(useComposerDraftStore.getState().getDraftThread(threadId)).toBeNull(); |
| 524 | expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined(); | |
| 523 | 525 | }); |
| 524 | 526 | |
| 525 | 527 | it("updates branch context on an existing draft thread", () => { |
apps/web/src/composerDraftStore.ts+11-37| ... | ... | @@ -219,7 +219,6 @@ interface ComposerDraftStoreState { |
| 219 | 219 | attachments: PersistedComposerImageAttachment[], |
| 220 | 220 | ) => void; |
| 221 | 221 | clearComposerContent: (threadId: ThreadId) => void; |
| 222 | clearThreadDraft: (threadId: ThreadId) => void; | |
| 223 | 222 | } |
| 224 | 223 | |
| 225 | 224 | const EMPTY_PROVIDER_MODEL_OPTIONS = Object.freeze<ProviderModelOptions>({}); |
| ... | ... | @@ -1189,12 +1188,19 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()( |
| 1189 | 1188 | if (threadId.length === 0) { |
| 1190 | 1189 | return; |
| 1191 | 1190 | } |
| 1191 | const existing = get().draftsByThreadId[threadId]; | |
| 1192 | if (existing) { | |
| 1193 | for (const image of existing.images) { | |
| 1194 | revokeObjectPreviewUrl(image.previewUrl); | |
| 1195 | } | |
| 1196 | } | |
| 1192 | 1197 | set((state) => { |
| 1193 | 1198 | const hasDraftThread = state.draftThreadsByThreadId[threadId] !== undefined; |
| 1194 | 1199 | const hasProjectMapping = Object.values(state.projectDraftThreadIdByProjectId).includes( |
| 1195 | 1200 | threadId, |
| 1196 | 1201 | ); |
| 1197 | if (!hasDraftThread && !hasProjectMapping) { | |
| 1202 | const hasComposerDraft = state.draftsByThreadId[threadId] !== undefined; | |
| 1203 | if (!hasDraftThread && !hasProjectMapping && !hasComposerDraft) { | |
| 1198 | 1204 | return state; |
| 1199 | 1205 | } |
| 1200 | 1206 | const nextProjectDraftThreadIdByProjectId = Object.fromEntries( |
| ... | ... | @@ -1204,7 +1210,10 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()( |
| 1204 | 1210 | ) as Record<ProjectId, ThreadId>; |
| 1205 | 1211 | const { [threadId]: _removedDraftThread, ...restDraftThreadsByThreadId } = |
| 1206 | 1212 | state.draftThreadsByThreadId; |
| 1213 | const { [threadId]: _removedComposerDraft, ...restDraftsByThreadId } = | |
| 1214 | state.draftsByThreadId; | |
| 1207 | 1215 | return { |
| 1216 | draftsByThreadId: restDraftsByThreadId, | |
| 1208 | 1217 | draftThreadsByThreadId: restDraftThreadsByThreadId, |
| 1209 | 1218 | projectDraftThreadIdByProjectId: nextProjectDraftThreadIdByProjectId, |
| 1210 | 1219 | }; |
| ... | ... | @@ -1738,41 +1747,6 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()( |
| 1738 | 1747 | return { draftsByThreadId: nextDraftsByThreadId }; |
| 1739 | 1748 | }); |
| 1740 | 1749 | }, |
| 1741 | clearThreadDraft: (threadId) => { | |
| 1742 | if (threadId.length === 0) { | |
| 1743 | return; | |
| 1744 | } | |
| 1745 | const existing = get().draftsByThreadId[threadId]; | |
| 1746 | if (existing) { | |
| 1747 | for (const image of existing.images) { | |
| 1748 | revokeObjectPreviewUrl(image.previewUrl); | |
| 1749 | } | |
| 1750 | } | |
| 1751 | set((state) => { | |
| 1752 | const hasComposerDraft = state.draftsByThreadId[threadId] !== undefined; | |
| 1753 | const hasDraftThread = state.draftThreadsByThreadId[threadId] !== undefined; | |
| 1754 | const hasProjectMapping = Object.values(state.projectDraftThreadIdByProjectId).includes( | |
| 1755 | threadId, | |
| 1756 | ); | |
| 1757 | if (!hasComposerDraft && !hasDraftThread && !hasProjectMapping) { | |
| 1758 | return state; | |
| 1759 | } | |
| 1760 | const { [threadId]: _removedComposerDraft, ...restComposerDraftsByThreadId } = | |
| 1761 | state.draftsByThreadId; | |
| 1762 | const { [threadId]: _removedDraftThread, ...restDraftThreadsByThreadId } = | |
| 1763 | state.draftThreadsByThreadId; | |
| 1764 | const nextProjectDraftThreadIdByProjectId = Object.fromEntries( | |
| 1765 | Object.entries(state.projectDraftThreadIdByProjectId).filter( | |
| 1766 | ([, draftThreadId]) => draftThreadId !== threadId, | |
| 1767 | ), | |
| 1768 | ) as Record<ProjectId, ThreadId>; | |
| 1769 | return { | |
| 1770 | draftsByThreadId: restComposerDraftsByThreadId, | |
| 1771 | draftThreadsByThreadId: restDraftThreadsByThreadId, | |
| 1772 | projectDraftThreadIdByProjectId: nextProjectDraftThreadIdByProjectId, | |
| 1773 | }; | |
| 1774 | }); | |
| 1775 | }, | |
| 1776 | 1750 | }), |
| 1777 | 1751 | { |
| 1778 | 1752 | name: COMPOSER_DRAFT_STORAGE_KEY, |
apps/web/src/lib/terminalFocus.test.ts created+56| ... | ... | @@ -0,0 +1,56 @@ |
| 1 | import { afterEach, describe, expect, it } from "vitest"; | |
| 2 | ||
| 3 | import { isTerminalFocused } from "./terminalFocus"; | |
| 4 | ||
| 5 | class MockHTMLElement { | |
| 6 | isConnected = false; | |
| 7 | className = ""; | |
| 8 | ||
| 9 | readonly classList = { | |
| 10 | contains: (value: string) => this.className.split(/\s+/).includes(value), | |
| 11 | }; | |
| 12 | ||
| 13 | closest(selector: string): MockHTMLElement | null { | |
| 14 | return selector === ".thread-terminal-drawer .xterm" && this.isConnected ? this : null; | |
| 15 | } | |
| 16 | } | |
| 17 | ||
| 18 | const originalDocument = globalThis.document; | |
| 19 | const originalHTMLElement = globalThis.HTMLElement; | |
| 20 | ||
| 21 | afterEach(() => { | |
| 22 | if (originalDocument === undefined) { | |
| 23 | delete (globalThis as { document?: Document }).document; | |
| 24 | } else { | |
| 25 | globalThis.document = originalDocument; | |
| 26 | } | |
| 27 | ||
| 28 | if (originalHTMLElement === undefined) { | |
| 29 | delete (globalThis as { HTMLElement?: typeof HTMLElement }).HTMLElement; | |
| 30 | } else { | |
| 31 | globalThis.HTMLElement = originalHTMLElement; | |
| 32 | } | |
| 33 | }); | |
| 34 | ||
| 35 | describe("isTerminalFocused", () => { | |
| 36 | it("returns false for detached xterm helper textareas", () => { | |
| 37 | const detached = new MockHTMLElement(); | |
| 38 | detached.className = "xterm-helper-textarea"; | |
| 39 | ||
| 40 | globalThis.HTMLElement = MockHTMLElement as unknown as typeof HTMLElement; | |
| 41 | globalThis.document = { activeElement: detached } as Document; | |
| 42 | ||
| 43 | expect(isTerminalFocused()).toBe(false); | |
| 44 | }); | |
| 45 | ||
| 46 | it("returns true for connected xterm helper textareas", () => { | |
| 47 | const attached = new MockHTMLElement(); | |
| 48 | attached.className = "xterm-helper-textarea"; | |
| 49 | attached.isConnected = true; | |
| 50 | ||
| 51 | globalThis.HTMLElement = MockHTMLElement as unknown as typeof HTMLElement; | |
| 52 | globalThis.document = { activeElement: attached } as Document; | |
| 53 | ||
| 54 | expect(isTerminalFocused()).toBe(true); | |
| 55 | }); | |
| 56 | }); |
apps/web/src/lib/terminalFocus.ts+1| ... | ... | @@ -1,6 +1,7 @@ |
| 1 | 1 | export function isTerminalFocused(): boolean { |
| 2 | 2 | const activeElement = document.activeElement; |
| 3 | 3 | if (!(activeElement instanceof HTMLElement)) return false; |
| 4 | if (!activeElement.isConnected) return false; | |
| 4 | 5 | if (activeElement.classList.contains("xterm-helper-textarea")) return true; |
| 5 | 6 | return activeElement.closest(".thread-terminal-drawer .xterm") !== null; |
| 6 | 7 | } |
apps/web/src/projectScripts.test.ts+16| ... | ... | @@ -4,6 +4,7 @@ import { |
| 4 | 4 | commandForProjectScript, |
| 5 | 5 | nextProjectScriptId, |
| 6 | 6 | primaryProjectScript, |
| 7 | projectScriptCwd, | |
| 7 | 8 | projectScriptRuntimeEnv, |
| 8 | 9 | projectScriptIdFromCommand, |
| 9 | 10 | setupProjectScript, |
| ... | ... | @@ -70,4 +71,19 @@ describe("projectScripts helpers", () => { |
| 70 | 71 | expect(env.CUSTOM_FLAG).toBe("1"); |
| 71 | 72 | expect(env.T3CODE_WORKTREE_PATH).toBeUndefined(); |
| 72 | 73 | }); |
| 74 | ||
| 75 | it("prefers the worktree path for script cwd resolution", () => { | |
| 76 | expect( | |
| 77 | projectScriptCwd({ | |
| 78 | project: { cwd: "/repo" }, | |
| 79 | worktreePath: "/repo/worktree-a", | |
| 80 | }), | |
| 81 | ).toBe("/repo/worktree-a"); | |
| 82 | expect( | |
| 83 | projectScriptCwd({ | |
| 84 | project: { cwd: "/repo" }, | |
| 85 | worktreePath: null, | |
| 86 | }), | |
| 87 | ).toBe("/repo"); | |
| 88 | }); | |
| 73 | 89 | }); |
apps/web/src/projectScripts.ts+9| ... | ... | @@ -63,6 +63,15 @@ interface ProjectScriptRuntimeEnvInput { |
| 63 | 63 | extraEnv?: Record<string, string>; |
| 64 | 64 | } |
| 65 | 65 | |
| 66 | export function projectScriptCwd(input: { | |
| 67 | project: { | |
| 68 | cwd: string; | |
| 69 | }; | |
| 70 | worktreePath?: string | null; | |
| 71 | }): string { | |
| 72 | return input.worktreePath ?? input.project.cwd; | |
| 73 | } | |
| 74 | ||
| 66 | 75 | export function projectScriptRuntimeEnv( |
| 67 | 76 | input: ProjectScriptRuntimeEnvInput, |
| 68 | 77 | ): Record<string, string> { |