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
1818apps/web/playwright-report
1919apps/web/src/components/__screenshots__
2020.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";
2424import {
2525 INLINE_TERMINAL_CONTEXT_PLACEHOLDER,
2626 type TerminalContextDraft,
27 removeInlineTerminalContextPlaceholder,
2728} from "../lib/terminalContext";
2829import { isMacPlatform } from "../lib/utils";
2930import { getRouter } from "../router";
......@@ -324,6 +325,18 @@ function createDraftOnlySnapshot(): OrchestrationReadModel {
324325 };
325326}
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
327340function createSnapshotWithLongProposedPlan(): OrchestrationReadModel {
328341 const snapshot = createSnapshotForTargetUser({
329342 targetMessageId: "msg-user-plan-target" as MessageId,
......@@ -575,6 +588,58 @@ async function waitForInteractionModeButton(
575588 );
576589}
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
578643async function waitForImagesToLoad(scope: ParentNode): Promise<void> {
579644 const images = Array.from(scope.querySelectorAll("img"));
580645 if (images.length === 0) {
......@@ -980,6 +1045,145 @@ describe("ChatView timeline estimator parity (full app)", () => {
9801045 }
9811046 });
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
9831187 it("toggles plan mode with Shift+Tab only while the composer is focused", async () => {
9841188 const mounted = await mountChatView({
9851189 viewport: DEFAULT_VIEWPORT,
......@@ -1045,7 +1249,7 @@ describe("ChatView timeline estimator parity (full app)", () => {
10451249 }
10461250 });
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 () => {
10491253 const removedLabel = "Terminal 1 lines 1-2";
10501254 const addedLabel = "Terminal 2 lines 9-10";
10511255 useComposerDraftStore.getState().addTerminalContext(
......@@ -1075,15 +1279,11 @@ describe("ChatView timeline estimator parity (full app)", () => {
10751279 { timeout: 8_000, interval: 16 },
10761280 );
10771281
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");
10871287
10881288 await vi.waitFor(
10891289 () => {
......@@ -1505,19 +1705,12 @@ describe("ChatView timeline estimator parity (full app)", () => {
15051705 });
15061706
15071707 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(
15211714 mounted.router,
15221715 (path) => UUID_ROUTE_RE.test(path),
15231716 "Route should have changed to a new draft thread UUID from the shortcut.",
......@@ -1526,7 +1719,6 @@ describe("ChatView timeline estimator parity (full app)", () => {
15261719 await mounted.cleanup();
15271720 }
15281721 });
1529
15301722 it("creates a fresh draft after the previous draft thread is promoted", async () => {
15311723 const mounted = await mountChatView({
15321724 viewport: DEFAULT_VIEWPORT,
......@@ -1561,6 +1753,8 @@ describe("ChatView timeline estimator parity (full app)", () => {
15611753 try {
15621754 const newThreadButton = page.getByTestId("new-thread-button");
15631755 await expect.element(newThreadButton).toBeInTheDocument();
1756 await waitForNewThreadShortcutLabel();
1757 await waitForServerConfigToApply();
15641758 await newThreadButton.click();
15651759
15661760 const promotedThreadPath = await waitForURL(
......@@ -1574,19 +1768,7 @@ describe("ChatView timeline estimator parity (full app)", () => {
15741768 syncServerReadModel(addThreadToSnapshot(fixture.snapshot, promotedThreadId));
15751769 useComposerDraftStore.getState().clearDraftThread(promotedThreadId);
15761770
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(
15901772 mounted.router,
15911773 (path) => UUID_ROUTE_RE.test(path) && path !== promotedThreadPath,
15921774 "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";
112112import {
113113 commandForProjectScript,
114114 nextProjectScriptId,
115 projectScriptCwd,
115116 projectScriptRuntimeEnv,
116117 projectScriptIdFromCommand,
117118 setupProjectScript,
......@@ -994,7 +995,12 @@ export default function ChatView({ threadId }: ChatViewProps) {
994995 latestTurnSettled,
995996 timelineEntries,
996997 ]);
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;
9981004 const composerTriggerKind = composerTrigger?.kind ?? null;
9991005 const pathTriggerQuery = composerTrigger?.kind === "path" ? composerTrigger.query : "";
10001006 const isPathTrigger = composerTriggerKind === "path";
......@@ -1303,12 +1309,10 @@ export default function ChatView({ threadId }: ChatViewProps) {
13031309 worktreePath?: string | null;
13041310 preferNewTerminal?: boolean;
13051311 rememberAsLastInvoked?: boolean;
1306 allowLocalDraftThread?: boolean;
13071312 },
13081313 ) => {
13091314 const api = readNativeApi();
13101315 if (!api || !activeThreadId || !activeProject || !activeThread) return;
1311 if (!isServerThread && !options?.allowLocalDraftThread) return;
13121316 if (options?.rememberAsLastInvoked !== false) {
13131317 setLastInvokedScriptByProjectId((current) => {
13141318 if (current[activeProject.id] === script.id) return current;
......@@ -1377,7 +1381,6 @@ export default function ChatView({ threadId }: ChatViewProps) {
13771381 activeThread,
13781382 activeThreadId,
13791383 gitCwd,
1380 isServerThread,
13811384 setTerminalOpen,
13821385 setThreadError,
13831386 storeNewTerminal,
......@@ -2566,7 +2569,6 @@ export default function ChatView({ threadId }: ChatViewProps) {
25662569 const setupScriptOptions: Parameters<typeof runProjectScript>[1] = {
25672570 worktreePath: nextThreadWorktreePath,
25682571 rememberAsLastInvoked: false,
2569 allowLocalDraftThread: createdServerThreadForLocalDraft,
25702572 };
25712573 if (nextThreadWorktreePath) {
25722574 setupScriptOptions.cwd = nextThreadWorktreePath;
......@@ -3465,7 +3467,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
34653467 activeThreadTitle={activeThread.title}
34663468 activeProjectName={activeProject?.name}
34673469 isGitRepo={isGitRepo}
3468 openInCwd={activeThread.worktreePath ?? activeProject?.cwd ?? null}
3470 openInCwd={gitCwd}
34693471 activeProjectScripts={activeProject?.scripts}
34703472 preferredScriptId={
34713473 activeProject ? (lastInvokedScriptByProjectId[activeProject.id] ?? null) : null
apps/web/src/components/Sidebar.tsx+1-1
......@@ -258,7 +258,7 @@ export default function Sidebar() {
258258 const markThreadUnread = useStore((store) => store.markThreadUnread);
259259 const toggleProject = useStore((store) => store.toggleProject);
260260 const reorderProjects = useStore((store) => store.reorderProjects);
261 const clearComposerDraftForThread = useComposerDraftStore((store) => store.clearThreadDraft);
261 const clearComposerDraftForThread = useComposerDraftStore((store) => store.clearDraftThread);
262262 const getDraftThreadByProjectId = useComposerDraftStore(
263263 (store) => store.getDraftThreadByProjectId,
264264 );
apps/web/src/composerDraftStore.test.ts+2
......@@ -517,9 +517,11 @@ describe("composerDraftStore project draft thread mapping", () => {
517517 it("clears draft registration independently", () => {
518518 const store = useComposerDraftStore.getState();
519519 store.setProjectDraftThreadId(projectId, threadId);
520 store.setPrompt(threadId, "remove me");
520521 store.clearDraftThread(threadId);
521522 expect(useComposerDraftStore.getState().getDraftThreadByProjectId(projectId)).toBeNull();
522523 expect(useComposerDraftStore.getState().getDraftThread(threadId)).toBeNull();
524 expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined();
523525 });
524526
525527 it("updates branch context on an existing draft thread", () => {
apps/web/src/composerDraftStore.ts+11-37
......@@ -219,7 +219,6 @@ interface ComposerDraftStoreState {
219219 attachments: PersistedComposerImageAttachment[],
220220 ) => void;
221221 clearComposerContent: (threadId: ThreadId) => void;
222 clearThreadDraft: (threadId: ThreadId) => void;
223222}
224223
225224const EMPTY_PROVIDER_MODEL_OPTIONS = Object.freeze<ProviderModelOptions>({});
......@@ -1189,12 +1188,19 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
11891188 if (threadId.length === 0) {
11901189 return;
11911190 }
1191 const existing = get().draftsByThreadId[threadId];
1192 if (existing) {
1193 for (const image of existing.images) {
1194 revokeObjectPreviewUrl(image.previewUrl);
1195 }
1196 }
11921197 set((state) => {
11931198 const hasDraftThread = state.draftThreadsByThreadId[threadId] !== undefined;
11941199 const hasProjectMapping = Object.values(state.projectDraftThreadIdByProjectId).includes(
11951200 threadId,
11961201 );
1197 if (!hasDraftThread && !hasProjectMapping) {
1202 const hasComposerDraft = state.draftsByThreadId[threadId] !== undefined;
1203 if (!hasDraftThread && !hasProjectMapping && !hasComposerDraft) {
11981204 return state;
11991205 }
12001206 const nextProjectDraftThreadIdByProjectId = Object.fromEntries(
......@@ -1204,7 +1210,10 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
12041210 ) as Record<ProjectId, ThreadId>;
12051211 const { [threadId]: _removedDraftThread, ...restDraftThreadsByThreadId } =
12061212 state.draftThreadsByThreadId;
1213 const { [threadId]: _removedComposerDraft, ...restDraftsByThreadId } =
1214 state.draftsByThreadId;
12071215 return {
1216 draftsByThreadId: restDraftsByThreadId,
12081217 draftThreadsByThreadId: restDraftThreadsByThreadId,
12091218 projectDraftThreadIdByProjectId: nextProjectDraftThreadIdByProjectId,
12101219 };
......@@ -1738,41 +1747,6 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
17381747 return { draftsByThreadId: nextDraftsByThreadId };
17391748 });
17401749 },
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 },
17761750 }),
17771751 {
17781752 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 @@
11export function isTerminalFocused(): boolean {
22 const activeElement = document.activeElement;
33 if (!(activeElement instanceof HTMLElement)) return false;
4 if (!activeElement.isConnected) return false;
45 if (activeElement.classList.contains("xterm-helper-textarea")) return true;
56 return activeElement.closest(".thread-terminal-drawer .xterm") !== null;
67}
apps/web/src/projectScripts.test.ts+16
......@@ -4,6 +4,7 @@ import {
44 commandForProjectScript,
55 nextProjectScriptId,
66 primaryProjectScript,
7 projectScriptCwd,
78 projectScriptRuntimeEnv,
89 projectScriptIdFromCommand,
910 setupProjectScript,
......@@ -70,4 +71,19 @@ describe("projectScripts helpers", () => {
7071 expect(env.CUSTOM_FLAG).toBe("1");
7172 expect(env.T3CODE_WORKTREE_PATH).toBeUndefined();
7273 });
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 });
7389});
apps/web/src/projectScripts.ts+9
......@@ -63,6 +63,15 @@ interface ProjectScriptRuntimeEnvInput {
6363 extraEnv?: Record<string, string>;
6464}
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
6675export function projectScriptRuntimeEnv(
6776 input: ProjectScriptRuntimeEnvInput,
6877): Record<string, string> {