diff --git a/.gitignore b/.gitignore index 3e8d28775..56db4e91b 100644 --- a/.gitignore +++ b/.gitignore @@ -17,4 +17,5 @@ release/ apps/web/.playwright apps/web/playwright-report apps/web/src/components/__screenshots__ -.vitest-* \ No newline at end of file +.vitest-* +.tanstack diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 7b31dbdf3..9f1d33ace 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -324,6 +324,18 @@ function createDraftOnlySnapshot(): OrchestrationReadModel { }; } +function withProjectScripts( + snapshot: OrchestrationReadModel, + scripts: OrchestrationReadModel["projects"][number]["scripts"], +): OrchestrationReadModel { + return { + ...snapshot, + projects: snapshot.projects.map((project) => + project.id === PROJECT_ID ? { ...project, scripts: Array.from(scripts) } : project, + ), + }; +} + function createSnapshotWithLongProposedPlan(): OrchestrationReadModel { const snapshot = createSnapshotForTargetUser({ targetMessageId: "msg-user-plan-target" as MessageId, @@ -573,6 +585,58 @@ async function waitForInteractionModeButton( ); } +async function waitForServerConfigToApply(): Promise { + await vi.waitFor( + () => { + expect(wsRequests.some((request) => request._tag === WS_METHODS.serverGetConfig)).toBe(true); + }, + { timeout: 8_000, interval: 16 }, + ); + await waitForLayout(); +} + +function dispatchChatNewShortcut(): void { + const useMetaForMod = isMacPlatform(navigator.platform); + window.dispatchEvent( + new KeyboardEvent("keydown", { + key: "o", + shiftKey: true, + metaKey: useMetaForMod, + ctrlKey: !useMetaForMod, + bubbles: true, + cancelable: true, + }), + ); +} + +async function triggerChatNewShortcutUntilPath( + router: ReturnType, + predicate: (pathname: string) => boolean, + errorMessage: string, +): Promise { + let pathname = router.state.location.pathname; + const deadline = Date.now() + 8_000; + while (Date.now() < deadline) { + dispatchChatNewShortcut(); + await waitForLayout(); + pathname = router.state.location.pathname; + if (predicate(pathname)) { + return pathname; + } + } + throw new Error(`${errorMessage} Last path: ${pathname}`); +} + +async function waitForNewThreadShortcutLabel(): Promise { + const newThreadButton = page.getByTestId("new-thread-button"); + await expect.element(newThreadButton).toBeInTheDocument(); + await newThreadButton.hover(); + const shortcutLabel = isMacPlatform(navigator.platform) + ? "New thread (⇧⌘O)" + : "New thread (Ctrl+Shift+O)"; + await expect.element(page.getByText(shortcutLabel)).toBeInTheDocument(); +} + async function waitForImagesToLoad(scope: ParentNode): Promise { const images = Array.from(scope.querySelectorAll("img")); if (images.length === 0) { @@ -976,6 +1040,145 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); + it("runs project scripts from local draft threads at the project cwd", async () => { + useComposerDraftStore.setState({ + draftThreadsByThreadId: { + [THREAD_ID]: { + projectId: PROJECT_ID, + createdAt: NOW_ISO, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + envMode: "local", + }, + }, + projectDraftThreadIdByProjectId: { + [PROJECT_ID]: THREAD_ID, + }, + }); + + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: withProjectScripts(createDraftOnlySnapshot(), [ + { + id: "lint", + name: "Lint", + command: "bun run lint", + icon: "lint", + runOnWorktreeCreate: false, + }, + ]), + }); + + try { + const runButton = await waitForElement( + () => + Array.from(document.querySelectorAll("button")).find( + (button) => button.title === "Run Lint", + ) as HTMLButtonElement | null, + "Unable to find Run Lint button.", + ); + runButton.click(); + + await vi.waitFor( + () => { + const openRequest = wsRequests.find( + (request) => request._tag === WS_METHODS.terminalOpen, + ); + expect(openRequest).toMatchObject({ + _tag: WS_METHODS.terminalOpen, + threadId: THREAD_ID, + cwd: "/repo/project", + env: { + T3CODE_PROJECT_ROOT: "/repo/project", + }, + }); + }, + { timeout: 8_000, interval: 16 }, + ); + + await vi.waitFor( + () => { + const writeRequest = wsRequests.find( + (request) => request._tag === WS_METHODS.terminalWrite, + ); + expect(writeRequest).toMatchObject({ + _tag: WS_METHODS.terminalWrite, + threadId: THREAD_ID, + data: "bun run lint\r", + }); + }, + { timeout: 8_000, interval: 16 }, + ); + } finally { + await mounted.cleanup(); + } + }); + + it("runs project scripts from worktree draft threads at the worktree cwd", async () => { + useComposerDraftStore.setState({ + draftThreadsByThreadId: { + [THREAD_ID]: { + projectId: PROJECT_ID, + createdAt: NOW_ISO, + runtimeMode: "full-access", + interactionMode: "default", + branch: "feature/draft", + worktreePath: "/repo/worktrees/feature-draft", + envMode: "worktree", + }, + }, + projectDraftThreadIdByProjectId: { + [PROJECT_ID]: THREAD_ID, + }, + }); + + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: withProjectScripts(createDraftOnlySnapshot(), [ + { + id: "test", + name: "Test", + command: "bun run test", + icon: "test", + runOnWorktreeCreate: false, + }, + ]), + }); + + try { + const runButton = await waitForElement( + () => + Array.from(document.querySelectorAll("button")).find( + (button) => button.title === "Run Test", + ) as HTMLButtonElement | null, + "Unable to find Run Test button.", + ); + runButton.click(); + + await vi.waitFor( + () => { + const openRequest = wsRequests.find( + (request) => request._tag === WS_METHODS.terminalOpen, + ); + expect(openRequest).toMatchObject({ + _tag: WS_METHODS.terminalOpen, + threadId: THREAD_ID, + cwd: "/repo/worktrees/feature-draft", + env: { + T3CODE_PROJECT_ROOT: "/repo/project", + T3CODE_WORKTREE_PATH: "/repo/worktrees/feature-draft", + }, + }); + }, + { timeout: 8_000, interval: 16 }, + ); + } finally { + await mounted.cleanup(); + } + }); + it("toggles plan mode with Shift+Tab only while the composer is focused", async () => { const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, @@ -1277,60 +1480,6 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); - it("creates a new thread from the global chat.new shortcut", async () => { - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-chat-shortcut-test" as MessageId, - targetText: "chat shortcut test", - }), - configureFixture: (nextFixture) => { - nextFixture.serverConfig = { - ...nextFixture.serverConfig, - keybindings: [ - { - command: "chat.new", - shortcut: { - key: "o", - metaKey: false, - ctrlKey: false, - shiftKey: true, - altKey: false, - modKey: true, - }, - whenAst: { - type: "not", - node: { type: "identifier", name: "terminalFocus" }, - }, - }, - ], - }; - }, - }); - - try { - const useMetaForMod = isMacPlatform(navigator.platform); - window.dispatchEvent( - new KeyboardEvent("keydown", { - key: "o", - shiftKey: true, - metaKey: useMetaForMod, - ctrlKey: !useMetaForMod, - bubbles: true, - cancelable: true, - }), - ); - - await waitForURL( - mounted.router, - (path) => UUID_ROUTE_RE.test(path), - "Route should have changed to a new draft thread UUID from the shortcut.", - ); - } finally { - await mounted.cleanup(); - } - }); - it("creates a fresh draft after the previous draft thread is promoted", async () => { const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, @@ -1365,6 +1514,8 @@ describe("ChatView timeline estimator parity (full app)", () => { try { const newThreadButton = page.getByTestId("new-thread-button"); await expect.element(newThreadButton).toBeInTheDocument(); + await waitForNewThreadShortcutLabel(); + await waitForServerConfigToApply(); await newThreadButton.click(); const promotedThreadPath = await waitForURL( @@ -1378,19 +1529,7 @@ describe("ChatView timeline estimator parity (full app)", () => { syncServerReadModel(addThreadToSnapshot(fixture.snapshot, promotedThreadId)); useComposerDraftStore.getState().clearDraftThread(promotedThreadId); - const useMetaForMod = isMacPlatform(navigator.platform); - window.dispatchEvent( - new KeyboardEvent("keydown", { - key: "o", - shiftKey: true, - metaKey: useMetaForMod, - ctrlKey: !useMetaForMod, - bubbles: true, - cancelable: true, - }), - ); - - const freshThreadPath = await waitForURL( + const freshThreadPath = await triggerChatNewShortcutUntilPath( mounted.router, (path) => UUID_ROUTE_RE.test(path) && path !== promotedThreadPath, "Shortcut should create a fresh draft instead of reusing the promoted thread.", diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 77cdb0ea1..2b95e6947 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -111,6 +111,7 @@ import { type NewProjectScriptInput } from "./ProjectScriptsControl"; import { commandForProjectScript, nextProjectScriptId, + projectScriptCwd, projectScriptRuntimeEnv, projectScriptIdFromCommand, setupProjectScript, @@ -980,7 +981,12 @@ export default function ChatView({ threadId }: ChatViewProps) { latestTurnSettled, timelineEntries, ]); - const gitCwd = activeThread?.worktreePath ?? activeProject?.cwd ?? null; + const gitCwd = activeProject + ? projectScriptCwd({ + project: { cwd: activeProject.cwd }, + worktreePath: activeThread?.worktreePath ?? null, + }) + : null; const composerTriggerKind = composerTrigger?.kind ?? null; const pathTriggerQuery = composerTrigger?.kind === "path" ? composerTrigger.query : ""; const isPathTrigger = composerTriggerKind === "path"; @@ -1290,12 +1296,10 @@ export default function ChatView({ threadId }: ChatViewProps) { worktreePath?: string | null; preferNewTerminal?: boolean; rememberAsLastInvoked?: boolean; - allowLocalDraftThread?: boolean; }, ) => { const api = readNativeApi(); if (!api || !activeThreadId || !activeProject || !activeThread) return; - if (!isServerThread && !options?.allowLocalDraftThread) return; if (options?.rememberAsLastInvoked !== false) { setLastInvokedScriptByProjectId((current) => { if (current[activeProject.id] === script.id) return current; @@ -1364,7 +1368,6 @@ export default function ChatView({ threadId }: ChatViewProps) { activeThread, activeThreadId, gitCwd, - isServerThread, setTerminalOpen, setThreadError, storeNewTerminal, @@ -2548,7 +2551,6 @@ export default function ChatView({ threadId }: ChatViewProps) { const setupScriptOptions: Parameters[1] = { worktreePath: nextThreadWorktreePath, rememberAsLastInvoked: false, - allowLocalDraftThread: createdServerThreadForLocalDraft, }; if (nextThreadWorktreePath) { setupScriptOptions.cwd = nextThreadWorktreePath; @@ -3412,7 +3414,7 @@ export default function ChatView({ threadId }: ChatViewProps) { activeThreadTitle={activeThread.title} activeProjectName={activeProject?.name} isGitRepo={isGitRepo} - openInCwd={activeThread.worktreePath ?? activeProject?.cwd ?? null} + openInCwd={gitCwd} activeProjectScripts={activeProject?.scripts} preferredScriptId={ activeProject ? (lastInvokedScriptByProjectId[activeProject.id] ?? null) : null diff --git a/apps/web/src/lib/terminalFocus.test.ts b/apps/web/src/lib/terminalFocus.test.ts new file mode 100644 index 000000000..832324ff1 --- /dev/null +++ b/apps/web/src/lib/terminalFocus.test.ts @@ -0,0 +1,56 @@ +import { afterEach, describe, expect, it } from "vitest"; + +import { isTerminalFocused } from "./terminalFocus"; + +class MockHTMLElement { + isConnected = false; + className = ""; + + readonly classList = { + contains: (value: string) => this.className.split(/\s+/).includes(value), + }; + + closest(selector: string): MockHTMLElement | null { + return selector === ".thread-terminal-drawer .xterm" && this.isConnected ? this : null; + } +} + +const originalDocument = globalThis.document; +const originalHTMLElement = globalThis.HTMLElement; + +afterEach(() => { + if (originalDocument === undefined) { + delete (globalThis as { document?: Document }).document; + } else { + globalThis.document = originalDocument; + } + + if (originalHTMLElement === undefined) { + delete (globalThis as { HTMLElement?: typeof HTMLElement }).HTMLElement; + } else { + globalThis.HTMLElement = originalHTMLElement; + } +}); + +describe("isTerminalFocused", () => { + it("returns false for detached xterm helper textareas", () => { + const detached = new MockHTMLElement(); + detached.className = "xterm-helper-textarea"; + + globalThis.HTMLElement = MockHTMLElement as unknown as typeof HTMLElement; + globalThis.document = { activeElement: detached } as Document; + + expect(isTerminalFocused()).toBe(false); + }); + + it("returns true for connected xterm helper textareas", () => { + const attached = new MockHTMLElement(); + attached.className = "xterm-helper-textarea"; + attached.isConnected = true; + + globalThis.HTMLElement = MockHTMLElement as unknown as typeof HTMLElement; + globalThis.document = { activeElement: attached } as Document; + + expect(isTerminalFocused()).toBe(true); + }); +}); diff --git a/apps/web/src/lib/terminalFocus.ts b/apps/web/src/lib/terminalFocus.ts index d24c9572a..d4edd9b14 100644 --- a/apps/web/src/lib/terminalFocus.ts +++ b/apps/web/src/lib/terminalFocus.ts @@ -1,6 +1,7 @@ export function isTerminalFocused(): boolean { const activeElement = document.activeElement; if (!(activeElement instanceof HTMLElement)) return false; + if (!activeElement.isConnected) return false; if (activeElement.classList.contains("xterm-helper-textarea")) return true; return activeElement.closest(".thread-terminal-drawer .xterm") !== null; } diff --git a/apps/web/src/projectScripts.test.ts b/apps/web/src/projectScripts.test.ts index dadc22a47..08678f873 100644 --- a/apps/web/src/projectScripts.test.ts +++ b/apps/web/src/projectScripts.test.ts @@ -4,6 +4,7 @@ import { commandForProjectScript, nextProjectScriptId, primaryProjectScript, + projectScriptCwd, projectScriptRuntimeEnv, projectScriptIdFromCommand, setupProjectScript, @@ -70,4 +71,19 @@ describe("projectScripts helpers", () => { expect(env.CUSTOM_FLAG).toBe("1"); expect(env.T3CODE_WORKTREE_PATH).toBeUndefined(); }); + + it("prefers the worktree path for script cwd resolution", () => { + expect( + projectScriptCwd({ + project: { cwd: "/repo" }, + worktreePath: "/repo/worktree-a", + }), + ).toBe("/repo/worktree-a"); + expect( + projectScriptCwd({ + project: { cwd: "/repo" }, + worktreePath: null, + }), + ).toBe("/repo"); + }); }); diff --git a/apps/web/src/projectScripts.ts b/apps/web/src/projectScripts.ts index e4ff752db..c11c3923b 100644 --- a/apps/web/src/projectScripts.ts +++ b/apps/web/src/projectScripts.ts @@ -63,6 +63,15 @@ interface ProjectScriptRuntimeEnvInput { extraEnv?: Record; } +export function projectScriptCwd(input: { + project: { + cwd: string; + }; + worktreePath?: string | null; +}): string { + return input.worktreePath ?? input.project.cwd; +} + export function projectScriptRuntimeEnv( input: ProjectScriptRuntimeEnvInput, ): Record {