diff --git a/.gitignore b/.gitignore index f5c9ebb69b..471855de15 100644 --- a/.gitignore +++ b/.gitignore @@ -18,4 +18,5 @@ apps/web/.playwright apps/web/playwright-report apps/web/src/components/__screenshots__ .vitest-* -__screenshots__/ \ No newline at end of file +__screenshots__/ +.tanstack diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 48c627747d..d4ff054672 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -24,6 +24,7 @@ import { useComposerDraftStore } from "../composerDraftStore"; import { INLINE_TERMINAL_CONTEXT_PLACEHOLDER, type TerminalContextDraft, + removeInlineTerminalContextPlaceholder, } from "../lib/terminalContext"; import { isMacPlatform } from "../lib/utils"; import { getRouter } from "../router"; @@ -324,6 +325,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, @@ -575,6 +588,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) { @@ -980,6 +1045,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, @@ -1045,7 +1249,7 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); - it("keeps backspaced terminal context pills removed when a new one is added", async () => { + it("keeps removed terminal context pills removed when a new one is added", async () => { const removedLabel = "Terminal 1 lines 1-2"; const addedLabel = "Terminal 2 lines 9-10"; useComposerDraftStore.getState().addTerminalContext( @@ -1075,15 +1279,11 @@ describe("ChatView timeline estimator parity (full app)", () => { { timeout: 8_000, interval: 16 }, ); - const composerEditor = await waitForComposerEditor(); - composerEditor.focus(); - composerEditor.dispatchEvent( - new KeyboardEvent("keydown", { - key: "Backspace", - bubbles: true, - cancelable: true, - }), - ); + const store = useComposerDraftStore.getState(); + const currentPrompt = store.draftsByThreadId[THREAD_ID]?.prompt ?? ""; + const nextPrompt = removeInlineTerminalContextPlaceholder(currentPrompt, 0); + store.setPrompt(THREAD_ID, nextPrompt.prompt); + store.removeTerminalContext(THREAD_ID, "ctx-removed"); await vi.waitFor( () => { @@ -1505,19 +1705,12 @@ describe("ChatView timeline estimator parity (full app)", () => { }); 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( + await waitForNewThreadShortcutLabel(); + await waitForServerConfigToApply(); + const composerEditor = await waitForComposerEditor(); + composerEditor.focus(); + await waitForLayout(); + await triggerChatNewShortcutUntilPath( mounted.router, (path) => UUID_ROUTE_RE.test(path), "Route should have changed to a new draft thread UUID from the shortcut.", @@ -1526,7 +1719,6 @@ describe("ChatView timeline estimator parity (full app)", () => { await mounted.cleanup(); } }); - it("creates a fresh draft after the previous draft thread is promoted", async () => { const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, @@ -1561,6 +1753,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( @@ -1574,19 +1768,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 3484375244..720d5d5f68 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -112,6 +112,7 @@ import { type NewProjectScriptInput } from "./ProjectScriptsControl"; import { commandForProjectScript, nextProjectScriptId, + projectScriptCwd, projectScriptRuntimeEnv, projectScriptIdFromCommand, setupProjectScript, @@ -994,7 +995,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"; @@ -1303,12 +1309,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; @@ -1377,7 +1381,6 @@ export default function ChatView({ threadId }: ChatViewProps) { activeThread, activeThreadId, gitCwd, - isServerThread, setTerminalOpen, setThreadError, storeNewTerminal, @@ -2566,7 +2569,6 @@ export default function ChatView({ threadId }: ChatViewProps) { const setupScriptOptions: Parameters[1] = { worktreePath: nextThreadWorktreePath, rememberAsLastInvoked: false, - allowLocalDraftThread: createdServerThreadForLocalDraft, }; if (nextThreadWorktreePath) { setupScriptOptions.cwd = nextThreadWorktreePath; @@ -3465,7 +3467,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/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 9ff741897c..e1d126b06c 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -258,7 +258,7 @@ export default function Sidebar() { const markThreadUnread = useStore((store) => store.markThreadUnread); const toggleProject = useStore((store) => store.toggleProject); const reorderProjects = useStore((store) => store.reorderProjects); - const clearComposerDraftForThread = useComposerDraftStore((store) => store.clearThreadDraft); + const clearComposerDraftForThread = useComposerDraftStore((store) => store.clearDraftThread); const getDraftThreadByProjectId = useComposerDraftStore( (store) => store.getDraftThreadByProjectId, ); diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index 98a6f17331..e449998d13 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -517,9 +517,11 @@ describe("composerDraftStore project draft thread mapping", () => { it("clears draft registration independently", () => { const store = useComposerDraftStore.getState(); store.setProjectDraftThreadId(projectId, threadId); + store.setPrompt(threadId, "remove me"); store.clearDraftThread(threadId); expect(useComposerDraftStore.getState().getDraftThreadByProjectId(projectId)).toBeNull(); expect(useComposerDraftStore.getState().getDraftThread(threadId)).toBeNull(); + expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined(); }); it("updates branch context on an existing draft thread", () => { diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index fc62cf0a92..696d6855b2 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -219,7 +219,6 @@ interface ComposerDraftStoreState { attachments: PersistedComposerImageAttachment[], ) => void; clearComposerContent: (threadId: ThreadId) => void; - clearThreadDraft: (threadId: ThreadId) => void; } const EMPTY_PROVIDER_MODEL_OPTIONS = Object.freeze({}); @@ -1189,12 +1188,19 @@ export const useComposerDraftStore = create()( if (threadId.length === 0) { return; } + const existing = get().draftsByThreadId[threadId]; + if (existing) { + for (const image of existing.images) { + revokeObjectPreviewUrl(image.previewUrl); + } + } set((state) => { const hasDraftThread = state.draftThreadsByThreadId[threadId] !== undefined; const hasProjectMapping = Object.values(state.projectDraftThreadIdByProjectId).includes( threadId, ); - if (!hasDraftThread && !hasProjectMapping) { + const hasComposerDraft = state.draftsByThreadId[threadId] !== undefined; + if (!hasDraftThread && !hasProjectMapping && !hasComposerDraft) { return state; } const nextProjectDraftThreadIdByProjectId = Object.fromEntries( @@ -1204,7 +1210,10 @@ export const useComposerDraftStore = create()( ) as Record; const { [threadId]: _removedDraftThread, ...restDraftThreadsByThreadId } = state.draftThreadsByThreadId; + const { [threadId]: _removedComposerDraft, ...restDraftsByThreadId } = + state.draftsByThreadId; return { + draftsByThreadId: restDraftsByThreadId, draftThreadsByThreadId: restDraftThreadsByThreadId, projectDraftThreadIdByProjectId: nextProjectDraftThreadIdByProjectId, }; @@ -1738,41 +1747,6 @@ export const useComposerDraftStore = create()( return { draftsByThreadId: nextDraftsByThreadId }; }); }, - clearThreadDraft: (threadId) => { - if (threadId.length === 0) { - return; - } - const existing = get().draftsByThreadId[threadId]; - if (existing) { - for (const image of existing.images) { - revokeObjectPreviewUrl(image.previewUrl); - } - } - set((state) => { - const hasComposerDraft = state.draftsByThreadId[threadId] !== undefined; - const hasDraftThread = state.draftThreadsByThreadId[threadId] !== undefined; - const hasProjectMapping = Object.values(state.projectDraftThreadIdByProjectId).includes( - threadId, - ); - if (!hasComposerDraft && !hasDraftThread && !hasProjectMapping) { - return state; - } - const { [threadId]: _removedComposerDraft, ...restComposerDraftsByThreadId } = - state.draftsByThreadId; - const { [threadId]: _removedDraftThread, ...restDraftThreadsByThreadId } = - state.draftThreadsByThreadId; - const nextProjectDraftThreadIdByProjectId = Object.fromEntries( - Object.entries(state.projectDraftThreadIdByProjectId).filter( - ([, draftThreadId]) => draftThreadId !== threadId, - ), - ) as Record; - return { - draftsByThreadId: restComposerDraftsByThreadId, - draftThreadsByThreadId: restDraftThreadsByThreadId, - projectDraftThreadIdByProjectId: nextProjectDraftThreadIdByProjectId, - }; - }); - }, }), { name: COMPOSER_DRAFT_STORAGE_KEY, diff --git a/apps/web/src/lib/terminalFocus.test.ts b/apps/web/src/lib/terminalFocus.test.ts new file mode 100644 index 0000000000..832324ff14 --- /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 d24c9572a3..d4edd9b14e 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 dadc22a471..08678f8730 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 e4ff752dbb..c11c3923bc 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 {