| 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,4 +18,5 @@ apps/web/.playwright |
| 18 | apps/web/playwright-report | 18 | apps/web/playwright-report |
| 19 | apps/web/src/components/__screenshots__ | 19 | apps/web/src/components/__screenshots__ |
| 20 | .vitest-* | 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,6 +24,7 @@ import { useComposerDraftStore } from "../composerDraftStore"; |
| 24 | import { | 24 | import { |
| 25 | INLINE_TERMINAL_CONTEXT_PLACEHOLDER, | 25 | INLINE_TERMINAL_CONTEXT_PLACEHOLDER, |
| 26 | type TerminalContextDraft, | 26 | type TerminalContextDraft, |
| 27 | removeInlineTerminalContextPlaceholder, | ||
| 27 | } from "../lib/terminalContext"; | 28 | } from "../lib/terminalContext"; |
| 28 | import { isMacPlatform } from "../lib/utils"; | 29 | import { isMacPlatform } from "../lib/utils"; |
| 29 | import { getRouter } from "../router"; | 30 | import { getRouter } from "../router"; |
| ... | @@ -324,6 +325,18 @@ function createDraftOnlySnapshot(): OrchestrationReadModel { | ... | @@ -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 | function createSnapshotWithLongProposedPlan(): OrchestrationReadModel { | 340 | function createSnapshotWithLongProposedPlan(): OrchestrationReadModel { |
| 328 | const snapshot = createSnapshotForTargetUser({ | 341 | const snapshot = createSnapshotForTargetUser({ |
| 329 | targetMessageId: "msg-user-plan-target" as MessageId, | 342 | targetMessageId: "msg-user-plan-target" as MessageId, |
| ... | @@ -575,6 +588,58 @@ async function waitForInteractionModeButton( | ... | @@ -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 | async function waitForImagesToLoad(scope: ParentNode): Promise<void> { | 643 | async function waitForImagesToLoad(scope: ParentNode): Promise<void> { |
| 579 | const images = Array.from(scope.querySelectorAll("img")); | 644 | const images = Array.from(scope.querySelectorAll("img")); |
| 580 | if (images.length === 0) { | 645 | if (images.length === 0) { |
| ... | @@ -980,6 +1045,145 @@ describe("ChatView timeline estimator parity (full app)", () => { | ... | @@ -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 | it("toggles plan mode with Shift+Tab only while the composer is focused", async () => { | 1187 | it("toggles plan mode with Shift+Tab only while the composer is focused", async () => { |
| 984 | const mounted = await mountChatView({ | 1188 | const mounted = await mountChatView({ |
| 985 | viewport: DEFAULT_VIEWPORT, | 1189 | viewport: DEFAULT_VIEWPORT, |
| ... | @@ -1045,7 +1249,7 @@ describe("ChatView timeline estimator parity (full app)", () => { | ... | @@ -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 | const removedLabel = "Terminal 1 lines 1-2"; | 1253 | const removedLabel = "Terminal 1 lines 1-2"; |
| 1050 | const addedLabel = "Terminal 2 lines 9-10"; | 1254 | const addedLabel = "Terminal 2 lines 9-10"; |
| 1051 | useComposerDraftStore.getState().addTerminalContext( | 1255 | useComposerDraftStore.getState().addTerminalContext( |
| ... | @@ -1075,15 +1279,11 @@ describe("ChatView timeline estimator parity (full app)", () => { | ... | @@ -1075,15 +1279,11 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 1075 | { timeout: 8_000, interval: 16 }, | 1279 | { timeout: 8_000, interval: 16 }, |
| 1076 | ); | 1280 | ); |
| 1077 | 1281 | ||
| 1078 | const composerEditor = await waitForComposerEditor(); | 1282 | const store = useComposerDraftStore.getState(); |
| 1079 | composerEditor.focus(); | 1283 | const currentPrompt = store.draftsByThreadId[THREAD_ID]?.prompt ?? ""; |
| 1080 | composerEditor.dispatchEvent( | 1284 | const nextPrompt = removeInlineTerminalContextPlaceholder(currentPrompt, 0); |
| 1081 | new KeyboardEvent("keydown", { | 1285 | store.setPrompt(THREAD_ID, nextPrompt.prompt); |
| 1082 | key: "Backspace", | 1286 | store.removeTerminalContext(THREAD_ID, "ctx-removed"); |
| 1083 | bubbles: true, | ||
| 1084 | cancelable: true, | ||
| 1085 | }), | ||
| 1086 | ); | ||
| 1087 | 1287 | ||
| 1088 | await vi.waitFor( | 1288 | await vi.waitFor( |
| 1089 | () => { | 1289 | () => { |
| ... | @@ -1505,19 +1705,12 @@ describe("ChatView timeline estimator parity (full app)", () => { | ... | @@ -1505,19 +1705,12 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 1505 | }); | 1705 | }); |
| 1506 | 1706 | ||
| 1507 | try { | 1707 | try { |
| 1508 | const useMetaForMod = isMacPlatform(navigator.platform); | 1708 | await waitForNewThreadShortcutLabel(); |
| 1509 | window.dispatchEvent( | 1709 | await waitForServerConfigToApply(); |
| 1510 | new KeyboardEvent("keydown", { | 1710 | const composerEditor = await waitForComposerEditor(); |
| 1511 | key: "o", | 1711 | composerEditor.focus(); |
| 1512 | shiftKey: true, | 1712 | await waitForLayout(); |
| 1513 | metaKey: useMetaForMod, | 1713 | await triggerChatNewShortcutUntilPath( |
| 1514 | ctrlKey: !useMetaForMod, | ||
| 1515 | bubbles: true, | ||
| 1516 | cancelable: true, | ||
| 1517 | }), | ||
| 1518 | ); | ||
| 1519 | |||
| 1520 | await waitForURL( | ||
| 1521 | mounted.router, | 1714 | mounted.router, |
| 1522 | (path) => UUID_ROUTE_RE.test(path), | 1715 | (path) => UUID_ROUTE_RE.test(path), |
| 1523 | "Route should have changed to a new draft thread UUID from the shortcut.", | 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,7 +1719,6 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 1526 | await mounted.cleanup(); | 1719 | await mounted.cleanup(); |
| 1527 | } | 1720 | } |
| 1528 | }); | 1721 | }); |
| 1529 | |||
| 1530 | it("creates a fresh draft after the previous draft thread is promoted", async () => { | 1722 | it("creates a fresh draft after the previous draft thread is promoted", async () => { |
| 1531 | const mounted = await mountChatView({ | 1723 | const mounted = await mountChatView({ |
| 1532 | viewport: DEFAULT_VIEWPORT, | 1724 | viewport: DEFAULT_VIEWPORT, |
| ... | @@ -1561,6 +1753,8 @@ describe("ChatView timeline estimator parity (full app)", () => { | ... | @@ -1561,6 +1753,8 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 1561 | try { | 1753 | try { |
| 1562 | const newThreadButton = page.getByTestId("new-thread-button"); | 1754 | const newThreadButton = page.getByTestId("new-thread-button"); |
| 1563 | await expect.element(newThreadButton).toBeInTheDocument(); | 1755 | await expect.element(newThreadButton).toBeInTheDocument(); |
| 1756 | await waitForNewThreadShortcutLabel(); | ||
| 1757 | await waitForServerConfigToApply(); | ||
| 1564 | await newThreadButton.click(); | 1758 | await newThreadButton.click(); |
| 1565 | 1759 | ||
| 1566 | const promotedThreadPath = await waitForURL( | 1760 | const promotedThreadPath = await waitForURL( |
| ... | @@ -1574,19 +1768,7 @@ describe("ChatView timeline estimator parity (full app)", () => { | ... | @@ -1574,19 +1768,7 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 1574 | syncServerReadModel(addThreadToSnapshot(fixture.snapshot, promotedThreadId)); | 1768 | syncServerReadModel(addThreadToSnapshot(fixture.snapshot, promotedThreadId)); |
| 1575 | useComposerDraftStore.getState().clearDraftThread(promotedThreadId); | 1769 | useComposerDraftStore.getState().clearDraftThread(promotedThreadId); |
| 1576 | 1770 | ||
| 1577 | const useMetaForMod = isMacPlatform(navigator.platform); | 1771 | const freshThreadPath = await triggerChatNewShortcutUntilPath( |
| 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( | ||
| 1590 | mounted.router, | 1772 | mounted.router, |
| 1591 | (path) => UUID_ROUTE_RE.test(path) && path !== promotedThreadPath, | 1773 | (path) => UUID_ROUTE_RE.test(path) && path !== promotedThreadPath, |
| 1592 | "Shortcut should create a fresh draft instead of reusing the promoted thread.", | 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,6 +112,7 @@ import { type NewProjectScriptInput } from "./ProjectScriptsControl"; |
| 112 | import { | 112 | import { |
| 113 | commandForProjectScript, | 113 | commandForProjectScript, |
| 114 | nextProjectScriptId, | 114 | nextProjectScriptId, |
| 115 | projectScriptCwd, | ||
| 115 | projectScriptRuntimeEnv, | 116 | projectScriptRuntimeEnv, |
| 116 | projectScriptIdFromCommand, | 117 | projectScriptIdFromCommand, |
| 117 | setupProjectScript, | 118 | setupProjectScript, |
| ... | @@ -994,7 +995,12 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -994,7 +995,12 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 994 | latestTurnSettled, | 995 | latestTurnSettled, |
| 995 | timelineEntries, | 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 | const composerTriggerKind = composerTrigger?.kind ?? null; | 1004 | const composerTriggerKind = composerTrigger?.kind ?? null; |
| 999 | const pathTriggerQuery = composerTrigger?.kind === "path" ? composerTrigger.query : ""; | 1005 | const pathTriggerQuery = composerTrigger?.kind === "path" ? composerTrigger.query : ""; |
| 1000 | const isPathTrigger = composerTriggerKind === "path"; | 1006 | const isPathTrigger = composerTriggerKind === "path"; |
| ... | @@ -1303,12 +1309,10 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1303,12 +1309,10 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1303 | worktreePath?: string | null; | 1309 | worktreePath?: string | null; |
| 1304 | preferNewTerminal?: boolean; | 1310 | preferNewTerminal?: boolean; |
| 1305 | rememberAsLastInvoked?: boolean; | 1311 | rememberAsLastInvoked?: boolean; |
| 1306 | allowLocalDraftThread?: boolean; | ||
| 1307 | }, | 1312 | }, |
| 1308 | ) => { | 1313 | ) => { |
| 1309 | const api = readNativeApi(); | 1314 | const api = readNativeApi(); |
| 1310 | if (!api || !activeThreadId || !activeProject || !activeThread) return; | 1315 | if (!api || !activeThreadId || !activeProject || !activeThread) return; |
| 1311 | if (!isServerThread && !options?.allowLocalDraftThread) return; | ||
| 1312 | if (options?.rememberAsLastInvoked !== false) { | 1316 | if (options?.rememberAsLastInvoked !== false) { |
| 1313 | setLastInvokedScriptByProjectId((current) => { | 1317 | setLastInvokedScriptByProjectId((current) => { |
| 1314 | if (current[activeProject.id] === script.id) return current; | 1318 | if (current[activeProject.id] === script.id) return current; |
| ... | @@ -1377,7 +1381,6 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1377,7 +1381,6 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1377 | activeThread, | 1381 | activeThread, |
| 1378 | activeThreadId, | 1382 | activeThreadId, |
| 1379 | gitCwd, | 1383 | gitCwd, |
| 1380 | isServerThread, | ||
| 1381 | setTerminalOpen, | 1384 | setTerminalOpen, |
| 1382 | setThreadError, | 1385 | setThreadError, |
| 1383 | storeNewTerminal, | 1386 | storeNewTerminal, |
| ... | @@ -2566,7 +2569,6 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -2566,7 +2569,6 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2566 | const setupScriptOptions: Parameters<typeof runProjectScript>[1] = { | 2569 | const setupScriptOptions: Parameters<typeof runProjectScript>[1] = { |
| 2567 | worktreePath: nextThreadWorktreePath, | 2570 | worktreePath: nextThreadWorktreePath, |
| 2568 | rememberAsLastInvoked: false, | 2571 | rememberAsLastInvoked: false, |
| 2569 | allowLocalDraftThread: createdServerThreadForLocalDraft, | ||
| 2570 | }; | 2572 | }; |
| 2571 | if (nextThreadWorktreePath) { | 2573 | if (nextThreadWorktreePath) { |
| 2572 | setupScriptOptions.cwd = nextThreadWorktreePath; | 2574 | setupScriptOptions.cwd = nextThreadWorktreePath; |
| ... | @@ -3465,7 +3467,7 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -3465,7 +3467,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 3465 | activeThreadTitle={activeThread.title} | 3467 | activeThreadTitle={activeThread.title} |
| 3466 | activeProjectName={activeProject?.name} | 3468 | activeProjectName={activeProject?.name} |
| 3467 | isGitRepo={isGitRepo} | 3469 | isGitRepo={isGitRepo} |
| 3468 | openInCwd={activeThread.worktreePath ?? activeProject?.cwd ?? null} | 3470 | openInCwd={gitCwd} |
| 3469 | activeProjectScripts={activeProject?.scripts} | 3471 | activeProjectScripts={activeProject?.scripts} |
| 3470 | preferredScriptId={ | 3472 | preferredScriptId={ |
| 3471 | activeProject ? (lastInvokedScriptByProjectId[activeProject.id] ?? null) : null | 3473 | activeProject ? (lastInvokedScriptByProjectId[activeProject.id] ?? null) : null |
apps/web/src/components/Sidebar.tsx+1-1| ... | @@ -258,7 +258,7 @@ export default function Sidebar() { | ... | @@ -258,7 +258,7 @@ export default function Sidebar() { |
| 258 | const markThreadUnread = useStore((store) => store.markThreadUnread); | 258 | const markThreadUnread = useStore((store) => store.markThreadUnread); |
| 259 | const toggleProject = useStore((store) => store.toggleProject); | 259 | const toggleProject = useStore((store) => store.toggleProject); |
| 260 | const reorderProjects = useStore((store) => store.reorderProjects); | 260 | const reorderProjects = useStore((store) => store.reorderProjects); |
| 261 | const clearComposerDraftForThread = useComposerDraftStore((store) => store.clearThreadDraft); | 261 | const clearComposerDraftForThread = useComposerDraftStore((store) => store.clearDraftThread); |
| 262 | const getDraftThreadByProjectId = useComposerDraftStore( | 262 | const getDraftThreadByProjectId = useComposerDraftStore( |
| 263 | (store) => store.getDraftThreadByProjectId, | 263 | (store) => store.getDraftThreadByProjectId, |
| 264 | ); | 264 | ); |
apps/web/src/composerDraftStore.test.ts+2| ... | @@ -517,9 +517,11 @@ describe("composerDraftStore project draft thread mapping", () => { | ... | @@ -517,9 +517,11 @@ describe("composerDraftStore project draft thread mapping", () => { |
| 517 | it("clears draft registration independently", () => { | 517 | it("clears draft registration independently", () => { |
| 518 | const store = useComposerDraftStore.getState(); | 518 | const store = useComposerDraftStore.getState(); |
| 519 | store.setProjectDraftThreadId(projectId, threadId); | 519 | store.setProjectDraftThreadId(projectId, threadId); |
| 520 | store.setPrompt(threadId, "remove me"); | ||
| 520 | store.clearDraftThread(threadId); | 521 | store.clearDraftThread(threadId); |
| 521 | expect(useComposerDraftStore.getState().getDraftThreadByProjectId(projectId)).toBeNull(); | 522 | expect(useComposerDraftStore.getState().getDraftThreadByProjectId(projectId)).toBeNull(); |
| 522 | expect(useComposerDraftStore.getState().getDraftThread(threadId)).toBeNull(); | 523 | expect(useComposerDraftStore.getState().getDraftThread(threadId)).toBeNull(); |
| 524 | expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined(); | ||
| 523 | }); | 525 | }); |
| 524 | 526 | ||
| 525 | it("updates branch context on an existing draft thread", () => { | 527 | it("updates branch context on an existing draft thread", () => { |
apps/web/src/composerDraftStore.ts+11-37| ... | @@ -219,7 +219,6 @@ interface ComposerDraftStoreState { | ... | @@ -219,7 +219,6 @@ interface ComposerDraftStoreState { |
| 219 | attachments: PersistedComposerImageAttachment[], | 219 | attachments: PersistedComposerImageAttachment[], |
| 220 | ) => void; | 220 | ) => void; |
| 221 | clearComposerContent: (threadId: ThreadId) => void; | 221 | clearComposerContent: (threadId: ThreadId) => void; |
| 222 | clearThreadDraft: (threadId: ThreadId) => void; | ||
| 223 | } | 222 | } |
| 224 | 223 | ||
| 225 | const EMPTY_PROVIDER_MODEL_OPTIONS = Object.freeze<ProviderModelOptions>({}); | 224 | const EMPTY_PROVIDER_MODEL_OPTIONS = Object.freeze<ProviderModelOptions>({}); |
| ... | @@ -1189,12 +1188,19 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()( | ... | @@ -1189,12 +1188,19 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()( |
| 1189 | if (threadId.length === 0) { | 1188 | if (threadId.length === 0) { |
| 1190 | return; | 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 | set((state) => { | 1197 | set((state) => { |
| 1193 | const hasDraftThread = state.draftThreadsByThreadId[threadId] !== undefined; | 1198 | const hasDraftThread = state.draftThreadsByThreadId[threadId] !== undefined; |
| 1194 | const hasProjectMapping = Object.values(state.projectDraftThreadIdByProjectId).includes( | 1199 | const hasProjectMapping = Object.values(state.projectDraftThreadIdByProjectId).includes( |
| 1195 | threadId, | 1200 | threadId, |
| 1196 | ); | 1201 | ); |
| 1197 | if (!hasDraftThread && !hasProjectMapping) { | 1202 | const hasComposerDraft = state.draftsByThreadId[threadId] !== undefined; |
| 1203 | if (!hasDraftThread && !hasProjectMapping && !hasComposerDraft) { | ||
| 1198 | return state; | 1204 | return state; |
| 1199 | } | 1205 | } |
| 1200 | const nextProjectDraftThreadIdByProjectId = Object.fromEntries( | 1206 | const nextProjectDraftThreadIdByProjectId = Object.fromEntries( |
| ... | @@ -1204,7 +1210,10 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()( | ... | @@ -1204,7 +1210,10 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()( |
| 1204 | ) as Record<ProjectId, ThreadId>; | 1210 | ) as Record<ProjectId, ThreadId>; |
| 1205 | const { [threadId]: _removedDraftThread, ...restDraftThreadsByThreadId } = | 1211 | const { [threadId]: _removedDraftThread, ...restDraftThreadsByThreadId } = |
| 1206 | state.draftThreadsByThreadId; | 1212 | state.draftThreadsByThreadId; |
| 1213 | const { [threadId]: _removedComposerDraft, ...restDraftsByThreadId } = | ||
| 1214 | state.draftsByThreadId; | ||
| 1207 | return { | 1215 | return { |
| 1216 | draftsByThreadId: restDraftsByThreadId, | ||
| 1208 | draftThreadsByThreadId: restDraftThreadsByThreadId, | 1217 | draftThreadsByThreadId: restDraftThreadsByThreadId, |
| 1209 | projectDraftThreadIdByProjectId: nextProjectDraftThreadIdByProjectId, | 1218 | projectDraftThreadIdByProjectId: nextProjectDraftThreadIdByProjectId, |
| 1210 | }; | 1219 | }; |
| ... | @@ -1738,41 +1747,6 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()( | ... | @@ -1738,41 +1747,6 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()( |
| 1738 | return { draftsByThreadId: nextDraftsByThreadId }; | 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 | name: COMPOSER_DRAFT_STORAGE_KEY, | 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,6 +1,7 @@ |
| 1 | export function isTerminalFocused(): boolean { | 1 | export function isTerminalFocused(): boolean { |
| 2 | const activeElement = document.activeElement; | 2 | const activeElement = document.activeElement; |
| 3 | if (!(activeElement instanceof HTMLElement)) return false; | 3 | if (!(activeElement instanceof HTMLElement)) return false; |
| 4 | if (!activeElement.isConnected) return false; | ||
| 4 | if (activeElement.classList.contains("xterm-helper-textarea")) return true; | 5 | if (activeElement.classList.contains("xterm-helper-textarea")) return true; |
| 5 | return activeElement.closest(".thread-terminal-drawer .xterm") !== null; | 6 | return activeElement.closest(".thread-terminal-drawer .xterm") !== null; |
| 6 | } | 7 | } |
apps/web/src/projectScripts.test.ts+16| ... | @@ -4,6 +4,7 @@ import { | ... | @@ -4,6 +4,7 @@ import { |
| 4 | commandForProjectScript, | 4 | commandForProjectScript, |
| 5 | nextProjectScriptId, | 5 | nextProjectScriptId, |
| 6 | primaryProjectScript, | 6 | primaryProjectScript, |
| 7 | projectScriptCwd, | ||
| 7 | projectScriptRuntimeEnv, | 8 | projectScriptRuntimeEnv, |
| 8 | projectScriptIdFromCommand, | 9 | projectScriptIdFromCommand, |
| 9 | setupProjectScript, | 10 | setupProjectScript, |
| ... | @@ -70,4 +71,19 @@ describe("projectScripts helpers", () => { | ... | @@ -70,4 +71,19 @@ describe("projectScripts helpers", () => { |
| 70 | expect(env.CUSTOM_FLAG).toBe("1"); | 71 | expect(env.CUSTOM_FLAG).toBe("1"); |
| 71 | expect(env.T3CODE_WORKTREE_PATH).toBeUndefined(); | 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,6 +63,15 @@ interface ProjectScriptRuntimeEnvInput { |
| 63 | extraEnv?: Record<string, string>; | 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 | export function projectScriptRuntimeEnv( | 75 | export function projectScriptRuntimeEnv( |
| 67 | input: ProjectScriptRuntimeEnvInput, | 76 | input: ProjectScriptRuntimeEnvInput, |
| 68 | ): Record<string, string> { | 77 | ): Record<string, string> { |