authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-03-24 01:49:32-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-03-24 01:49:32-07:00
log1371ce2ccef7342399a6a020e69073334cfa364b
tree9806158223236d57be1b76a2a94d5595fd79786e
parentd6408a29494c27d55a71fbb62f9ea9a8c6241786
signature Signed by PGP key B5690EEEBB952194

Run draft-thread project scripts from resolved project/worktree cwd (#1178)

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
18apps/web/playwright-report18apps/web/playwright-report
19apps/web/src/components/__screenshots__19apps/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";
24import {24import {
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";
28import { isMacPlatform } from "../lib/utils";29import { isMacPlatform } from "../lib/utils";
29import { getRouter } from "../router";30import { getRouter } from "../router";
...@@ -324,6 +325,18 @@ function createDraftOnlySnapshot(): OrchestrationReadModel {...@@ -324,6 +325,18 @@ function createDraftOnlySnapshot(): OrchestrationReadModel {
324 };325 };
325}326}
326327
328function 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
327function createSnapshotWithLongProposedPlan(): OrchestrationReadModel {340function 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}
577590
591async 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
601function 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
615async 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
633async 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
578async function waitForImagesToLoad(scope: ParentNode): Promise<void> {643async 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 });
9821047
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 });
10471251
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 );
10771281
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 );
10871287
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 });
15061706
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();
15651759
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);
15761770
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";
112import {112import {
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) : null3473 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 });
524526
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}
224223
225const EMPTY_PROVIDER_MODEL_OPTIONS = Object.freeze<ProviderModelOptions>({});224const 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 @@
1import { afterEach, describe, expect, it } from "vitest";
2
3import { isTerminalFocused } from "./terminalFocus";
4
5class 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
18const originalDocument = globalThis.document;
19const originalHTMLElement = globalThis.HTMLElement;
20
21afterEach(() => {
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
35describe("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 @@
1export function isTerminalFocused(): boolean {1export 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}
6565
66export function projectScriptCwd(input: {
67 project: {
68 cwd: string;
69 };
70 worktreePath?: string | null;
71}): string {
72 return input.worktreePath ?? input.project.cwd;
73}
74
66export function projectScriptRuntimeEnv(75export function projectScriptRuntimeEnv(
67 input: ProjectScriptRuntimeEnvInput,76 input: ProjectScriptRuntimeEnvInput,
68): Record<string, string> {77): Record<string, string> {