From c63ea8d25b4d05d32fc061736bc6a0934ac682fb Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 5 Mar 2026 23:22:32 -0800 Subject: [PATCH] Use project cwd when opening draft threads without worktree - Pass resolved `openInCwd` from ChatView into OpenInPicker - Fall back to project `cwd` when draft thread has no `worktreePath` - Add browser test covering Open action for draft-only snapshot --- apps/web/src/components/ChatView.browser.tsx | 77 ++++++++++++++++++++ apps/web/src/components/ChatView.tsx | 33 ++++----- 2 files changed, 90 insertions(+), 20 deletions(-) diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 7ae275480b414cf522dead478dac9af289f3b747..13b6f28e16a94780bd70a4791bb68bd550078133 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -19,6 +19,7 @@ import { page } from "vitest/browser"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { render } from "vitest-browser-react"; +import { useComposerDraftStore } from "../composerDraftStore"; import { getRouter } from "../router"; import { useStore } from "../store"; import { estimateTimelineMessageHeight } from "./timelineHeight"; @@ -44,6 +45,7 @@ interface TestFixture { } let fixture: TestFixture; +const wsRequests: WsRequestEnvelope["body"][] = []; const wsLink = ws.link(/ws(s)?:\/\/.*/); interface ViewportSpec { @@ -243,6 +245,17 @@ function buildFixture(snapshot: OrchestrationReadModel): TestFixture { }; } +function createDraftOnlySnapshot(): OrchestrationReadModel { + const snapshot = createSnapshotForTargetUser({ + targetMessageId: "msg-user-draft-target" as MessageId, + targetText: "draft thread", + }); + return { + ...snapshot, + threads: [], + }; +} + function resolveWsRpc(tag: string): unknown { if (tag === ORCHESTRATION_WS_METHODS.getSnapshot) { return fixture.snapshot; @@ -307,6 +320,7 @@ const worker = setupWorker( } const method = request.body?._tag; if (typeof method !== "string") return; + wsRequests.push(request.body); client.send( JSON.stringify({ id: request.id, @@ -466,8 +480,10 @@ async function measureUserRow(options: { async function mountChatView(options: { viewport: ViewportSpec; snapshot: OrchestrationReadModel; + configureFixture?: (fixture: TestFixture) => void; }): Promise { fixture = buildFixture(options.snapshot); + options.configureFixture?.(fixture); await setViewport(options.viewport); await waitForProductionStyles(); @@ -547,6 +563,12 @@ describe("ChatView timeline estimator parity (full app)", () => { await setViewport(DEFAULT_VIEWPORT); localStorage.clear(); document.body.innerHTML = ""; + wsRequests.length = 0; + useComposerDraftStore.setState({ + draftsByThreadId: {}, + draftThreadsByThreadId: {}, + projectDraftThreadIdByProjectId: {}, + }); useStore.setState({ projects: [], threads: [], @@ -708,4 +730,59 @@ describe("ChatView timeline estimator parity (full app)", () => { } }, ); + + it("opens the project cwd for draft threads without a worktree path", 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: createDraftOnlySnapshot(), + configureFixture: (nextFixture) => { + nextFixture.serverConfig = { + ...nextFixture.serverConfig, + availableEditors: ["vscode"], + }; + }, + }); + + try { + const openButton = await waitForElement( + () => + Array.from(document.querySelectorAll("button")).find( + (button) => button.textContent?.trim() === "Open", + ) as HTMLButtonElement | null, + "Unable to find Open button.", + ); + openButton.click(); + + await vi.waitFor( + () => { + const openRequest = wsRequests.find((request) => request._tag === WS_METHODS.shellOpenInEditor); + expect(openRequest).toMatchObject({ + _tag: WS_METHODS.shellOpenInEditor, + cwd: "/repo/project", + editor: "vscode", + }); + }, + { timeout: 8_000, interval: 16 }, + ); + } finally { + await mounted.cleanup(); + } + }); }); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 63def586487c3f4bfa05683cae42fcf1165a411b..f80a0553e3f02fcbc5c0501805b85b47fad69adc 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -3293,6 +3293,7 @@ export default function ChatView({ threadId }: ChatViewProps) { activeThreadId={activeThread.id} activeThreadTitle={activeThread.title} activeProjectName={activeProject?.name} + openInCwd={activeThread.worktreePath ?? activeProject?.cwd ?? null} activeProjectScripts={activeProject?.scripts} preferredScriptId={ activeProject ? (lastInvokedScriptByProjectId[activeProject.id] ?? null) : null @@ -3855,6 +3856,7 @@ interface ChatHeaderProps { activeThreadId: ThreadId; activeThreadTitle: string; activeProjectName: string | undefined; + openInCwd: string | null; activeProjectScripts: ProjectScript[] | undefined; preferredScriptId: string | null; keybindings: ResolvedKeybindingsConfig; @@ -3872,6 +3874,7 @@ const ChatHeader = memo(function ChatHeader({ activeThreadId, activeThreadTitle, activeProjectName, + openInCwd, activeProjectScripts, preferredScriptId, keybindings, @@ -3915,7 +3918,7 @@ const ChatHeader = memo(function ChatHeader({ )} {activeProjectName && } @@ -5392,11 +5395,11 @@ const CodexTraitsPicker = memo(function CodexTraitsPicker(props: { const OpenInPicker = memo(function OpenInPicker({ keybindings, availableEditors, - activeThreadId, + openInCwd, }: { keybindings: ResolvedKeybindingsConfig; availableEditors: ReadonlyArray; - activeThreadId: ThreadId | null; + openInCwd: string | null; }) { const [lastEditor, setLastEditor] = useState(() => { const stored = localStorage.getItem(LAST_EDITOR_KEY); @@ -5442,26 +5445,17 @@ const OpenInPicker = memo(function OpenInPicker({ : (options[0]?.value ?? null); const primaryOption = options.find(({ value }) => value === effectiveEditor) ?? null; - const activeThread = useStore((store) => - activeThreadId ? store.threads.find((thread) => thread.id === activeThreadId) : undefined, - ); - const activeProjectId = activeThread?.projectId ?? null; - const activeProject = useStore((store) => - activeProjectId ? store.projects.find((project) => project.id === activeProjectId) : undefined, - ); - const openInEditor = useCallback( (editorId: EditorId | null) => { const api = readNativeApi(); - if (!api || !activeProject) return; + if (!api || !openInCwd) return; const editor = editorId ?? effectiveEditor; if (!editor) return; - const cwd = activeThread?.worktreePath ?? activeProject.cwd; - void api.shell.openInEditor(cwd, editor); + void api.shell.openInEditor(openInCwd, editor); localStorage.setItem(LAST_EDITOR_KEY, editor); setLastEditor(editor); }, - [activeProject, activeThread, effectiveEditor, setLastEditor], + [effectiveEditor, openInCwd, setLastEditor], ); const openFavoriteEditorShortcutLabel = useMemo( @@ -5473,23 +5467,22 @@ const OpenInPicker = memo(function OpenInPicker({ const handler = (e: globalThis.KeyboardEvent) => { const api = readNativeApi(); if (!isOpenFavoriteEditorShortcut(e, keybindings)) return; - if (!api || !activeProject) return; + if (!api || !openInCwd) return; if (!effectiveEditor) return; e.preventDefault(); - const cwd = activeThread?.worktreePath ?? activeProject.cwd; - void api.shell.openInEditor(cwd, effectiveEditor); + void api.shell.openInEditor(openInCwd, effectiveEditor); }; window.addEventListener("keydown", handler); return () => window.removeEventListener("keydown", handler); - }, [activeProject, activeThread, keybindings, effectiveEditor]); + }, [effectiveEditor, keybindings, openInCwd]); return (