Skip to content

Commit 1371ce2

Browse files
Run draft-thread project scripts from resolved project/worktree cwd (#1178)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
1 parent d6408a2 commit 1371ce2

10 files changed

Lines changed: 325 additions & 82 deletions

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,4 +18,5 @@ apps/web/.playwright
1818
apps/web/playwright-report
1919
apps/web/src/components/__screenshots__
2020
.vitest-*
21-
__screenshots__/
21+
__screenshots__/
22+
.tanstack

apps/web/src/components/ChatView.browser.tsx

Lines changed: 219 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { useComposerDraftStore } from "../composerDraftStore";
2424
import {
2525
INLINE_TERMINAL_CONTEXT_PLACEHOLDER,
2626
type TerminalContextDraft,
27+
removeInlineTerminalContextPlaceholder,
2728
} from "../lib/terminalContext";
2829
import { isMacPlatform } from "../lib/utils";
2930
import { getRouter } from "../router";
@@ -324,6 +325,18 @@ function createDraftOnlySnapshot(): OrchestrationReadModel {
324325
};
325326
}
326327

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+
327340
function createSnapshotWithLongProposedPlan(): OrchestrationReadModel {
328341
const snapshot = createSnapshotForTargetUser({
329342
targetMessageId: "msg-user-plan-target" as MessageId,
@@ -575,6 +588,58 @@ async function waitForInteractionModeButton(
575588
);
576589
}
577590

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+
578643
async 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

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ import { type NewProjectScriptInput } from "./ProjectScriptsControl";
112112
import {
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

0 commit comments

Comments
 (0)