authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-03-05 23:22:32-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-03-05 23:44:26-08:00
logc63ea8d25b4d05d32fc061736bc6a0934ac682fb
treeace1b9770d63b2ef353d94679fdcf816e2cd6d3b
parentb9e529e0b56b995728ff10cd1b643df63b436258

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

2 files changed, 90 insertions(+), 20 deletions(-)

apps/web/src/components/ChatView.browser.tsx+77
...@@ -19,6 +19,7 @@ import { page } from "vitest/browser";...@@ -19,6 +19,7 @@ import { page } from "vitest/browser";
19import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";19import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
20import { render } from "vitest-browser-react";20import { render } from "vitest-browser-react";
2121
22import { useComposerDraftStore } from "../composerDraftStore";
22import { getRouter } from "../router";23import { getRouter } from "../router";
23import { useStore } from "../store";24import { useStore } from "../store";
24import { estimateTimelineMessageHeight } from "./timelineHeight";25import { estimateTimelineMessageHeight } from "./timelineHeight";
...@@ -44,6 +45,7 @@ interface TestFixture {...@@ -44,6 +45,7 @@ interface TestFixture {
44}45}
4546
46let fixture: TestFixture;47let fixture: TestFixture;
48const wsRequests: WsRequestEnvelope["body"][] = [];
47const wsLink = ws.link(/ws(s)?:\/\/.*/);49const wsLink = ws.link(/ws(s)?:\/\/.*/);
4850
49interface ViewportSpec {51interface ViewportSpec {
...@@ -243,6 +245,17 @@ function buildFixture(snapshot: OrchestrationReadModel): TestFixture {...@@ -243,6 +245,17 @@ function buildFixture(snapshot: OrchestrationReadModel): TestFixture {
243 };245 };
244}246}
245247
248function createDraftOnlySnapshot(): OrchestrationReadModel {
249 const snapshot = createSnapshotForTargetUser({
250 targetMessageId: "msg-user-draft-target" as MessageId,
251 targetText: "draft thread",
252 });
253 return {
254 ...snapshot,
255 threads: [],
256 };
257}
258
246function resolveWsRpc(tag: string): unknown {259function resolveWsRpc(tag: string): unknown {
247 if (tag === ORCHESTRATION_WS_METHODS.getSnapshot) {260 if (tag === ORCHESTRATION_WS_METHODS.getSnapshot) {
248 return fixture.snapshot;261 return fixture.snapshot;
...@@ -307,6 +320,7 @@ const worker = setupWorker(...@@ -307,6 +320,7 @@ const worker = setupWorker(
307 }320 }
308 const method = request.body?._tag;321 const method = request.body?._tag;
309 if (typeof method !== "string") return;322 if (typeof method !== "string") return;
323 wsRequests.push(request.body);
310 client.send(324 client.send(
311 JSON.stringify({325 JSON.stringify({
312 id: request.id,326 id: request.id,
...@@ -466,8 +480,10 @@ async function measureUserRow(options: {...@@ -466,8 +480,10 @@ async function measureUserRow(options: {
466async function mountChatView(options: {480async function mountChatView(options: {
467 viewport: ViewportSpec;481 viewport: ViewportSpec;
468 snapshot: OrchestrationReadModel;482 snapshot: OrchestrationReadModel;
483 configureFixture?: (fixture: TestFixture) => void;
469}): Promise<MountedChatView> {484}): Promise<MountedChatView> {
470 fixture = buildFixture(options.snapshot);485 fixture = buildFixture(options.snapshot);
486 options.configureFixture?.(fixture);
471 await setViewport(options.viewport);487 await setViewport(options.viewport);
472 await waitForProductionStyles();488 await waitForProductionStyles();
473489
...@@ -547,6 +563,12 @@ describe("ChatView timeline estimator parity (full app)", () => {...@@ -547,6 +563,12 @@ describe("ChatView timeline estimator parity (full app)", () => {
547 await setViewport(DEFAULT_VIEWPORT);563 await setViewport(DEFAULT_VIEWPORT);
548 localStorage.clear();564 localStorage.clear();
549 document.body.innerHTML = "";565 document.body.innerHTML = "";
566 wsRequests.length = 0;
567 useComposerDraftStore.setState({
568 draftsByThreadId: {},
569 draftThreadsByThreadId: {},
570 projectDraftThreadIdByProjectId: {},
571 });
550 useStore.setState({572 useStore.setState({
551 projects: [],573 projects: [],
552 threads: [],574 threads: [],
...@@ -708,4 +730,59 @@ describe("ChatView timeline estimator parity (full app)", () => {...@@ -708,4 +730,59 @@ describe("ChatView timeline estimator parity (full app)", () => {
708 }730 }
709 },731 },
710 );732 );
733
734 it("opens the project cwd for draft threads without a worktree path", async () => {
735 useComposerDraftStore.setState({
736 draftThreadsByThreadId: {
737 [THREAD_ID]: {
738 projectId: PROJECT_ID,
739 createdAt: NOW_ISO,
740 runtimeMode: "full-access",
741 interactionMode: "default",
742 branch: null,
743 worktreePath: null,
744 envMode: "local",
745 },
746 },
747 projectDraftThreadIdByProjectId: {
748 [PROJECT_ID]: THREAD_ID,
749 },
750 });
751
752 const mounted = await mountChatView({
753 viewport: DEFAULT_VIEWPORT,
754 snapshot: createDraftOnlySnapshot(),
755 configureFixture: (nextFixture) => {
756 nextFixture.serverConfig = {
757 ...nextFixture.serverConfig,
758 availableEditors: ["vscode"],
759 };
760 },
761 });
762
763 try {
764 const openButton = await waitForElement(
765 () =>
766 Array.from(document.querySelectorAll("button")).find(
767 (button) => button.textContent?.trim() === "Open",
768 ) as HTMLButtonElement | null,
769 "Unable to find Open button.",
770 );
771 openButton.click();
772
773 await vi.waitFor(
774 () => {
775 const openRequest = wsRequests.find((request) => request._tag === WS_METHODS.shellOpenInEditor);
776 expect(openRequest).toMatchObject({
777 _tag: WS_METHODS.shellOpenInEditor,
778 cwd: "/repo/project",
779 editor: "vscode",
780 });
781 },
782 { timeout: 8_000, interval: 16 },
783 );
784 } finally {
785 await mounted.cleanup();
786 }
787 });
711});788});
apps/web/src/components/ChatView.tsx+13-20
...@@ -3293,6 +3293,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -3293,6 +3293,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
3293 activeThreadId={activeThread.id}3293 activeThreadId={activeThread.id}
3294 activeThreadTitle={activeThread.title}3294 activeThreadTitle={activeThread.title}
3295 activeProjectName={activeProject?.name}3295 activeProjectName={activeProject?.name}
3296 openInCwd={activeThread.worktreePath ?? activeProject?.cwd ?? null}
3296 activeProjectScripts={activeProject?.scripts}3297 activeProjectScripts={activeProject?.scripts}
3297 preferredScriptId={3298 preferredScriptId={
3298 activeProject ? (lastInvokedScriptByProjectId[activeProject.id] ?? null) : null3299 activeProject ? (lastInvokedScriptByProjectId[activeProject.id] ?? null) : null
...@@ -3855,6 +3856,7 @@ interface ChatHeaderProps {...@@ -3855,6 +3856,7 @@ interface ChatHeaderProps {
3855 activeThreadId: ThreadId;3856 activeThreadId: ThreadId;
3856 activeThreadTitle: string;3857 activeThreadTitle: string;
3857 activeProjectName: string | undefined;3858 activeProjectName: string | undefined;
3859 openInCwd: string | null;
3858 activeProjectScripts: ProjectScript[] | undefined;3860 activeProjectScripts: ProjectScript[] | undefined;
3859 preferredScriptId: string | null;3861 preferredScriptId: string | null;
3860 keybindings: ResolvedKeybindingsConfig;3862 keybindings: ResolvedKeybindingsConfig;
...@@ -3872,6 +3874,7 @@ const ChatHeader = memo(function ChatHeader({...@@ -3872,6 +3874,7 @@ const ChatHeader = memo(function ChatHeader({
3872 activeThreadId,3874 activeThreadId,
3873 activeThreadTitle,3875 activeThreadTitle,
3874 activeProjectName,3876 activeProjectName,
3877 openInCwd,
3875 activeProjectScripts,3878 activeProjectScripts,
3876 preferredScriptId,3879 preferredScriptId,
3877 keybindings,3880 keybindings,
...@@ -3915,7 +3918,7 @@ const ChatHeader = memo(function ChatHeader({...@@ -3915,7 +3918,7 @@ const ChatHeader = memo(function ChatHeader({
3915 <OpenInPicker3918 <OpenInPicker
3916 keybindings={keybindings}3919 keybindings={keybindings}
3917 availableEditors={availableEditors}3920 availableEditors={availableEditors}
3918 activeThreadId={activeThreadId}3921 openInCwd={openInCwd}
3919 />3922 />
3920 )}3923 )}
3921 {activeProjectName && <GitActionsControl gitCwd={gitCwd} activeThreadId={activeThreadId} />}3924 {activeProjectName && <GitActionsControl gitCwd={gitCwd} activeThreadId={activeThreadId} />}
...@@ -5392,11 +5395,11 @@ const CodexTraitsPicker = memo(function CodexTraitsPicker(props: {...@@ -5392,11 +5395,11 @@ const CodexTraitsPicker = memo(function CodexTraitsPicker(props: {
5392const OpenInPicker = memo(function OpenInPicker({5395const OpenInPicker = memo(function OpenInPicker({
5393 keybindings,5396 keybindings,
5394 availableEditors,5397 availableEditors,
5395 activeThreadId,5398 openInCwd,
5396}: {5399}: {
5397 keybindings: ResolvedKeybindingsConfig;5400 keybindings: ResolvedKeybindingsConfig;
5398 availableEditors: ReadonlyArray<EditorId>;5401 availableEditors: ReadonlyArray<EditorId>;
5399 activeThreadId: ThreadId | null;5402 openInCwd: string | null;
5400}) {5403}) {
5401 const [lastEditor, setLastEditor] = useState<EditorId>(() => {5404 const [lastEditor, setLastEditor] = useState<EditorId>(() => {
5402 const stored = localStorage.getItem(LAST_EDITOR_KEY);5405 const stored = localStorage.getItem(LAST_EDITOR_KEY);
...@@ -5442,26 +5445,17 @@ const OpenInPicker = memo(function OpenInPicker({...@@ -5442,26 +5445,17 @@ const OpenInPicker = memo(function OpenInPicker({
5442 : (options[0]?.value ?? null);5445 : (options[0]?.value ?? null);
5443 const primaryOption = options.find(({ value }) => value === effectiveEditor) ?? null;5446 const primaryOption = options.find(({ value }) => value === effectiveEditor) ?? null;
54445447
5445 const activeThread = useStore((store) =>
5446 activeThreadId ? store.threads.find((thread) => thread.id === activeThreadId) : undefined,
5447 );
5448 const activeProjectId = activeThread?.projectId ?? null;
5449 const activeProject = useStore((store) =>
5450 activeProjectId ? store.projects.find((project) => project.id === activeProjectId) : undefined,
5451 );
5452
5453 const openInEditor = useCallback(5448 const openInEditor = useCallback(
5454 (editorId: EditorId | null) => {5449 (editorId: EditorId | null) => {
5455 const api = readNativeApi();5450 const api = readNativeApi();
5456 if (!api || !activeProject) return;5451 if (!api || !openInCwd) return;
5457 const editor = editorId ?? effectiveEditor;5452 const editor = editorId ?? effectiveEditor;
5458 if (!editor) return;5453 if (!editor) return;
5459 const cwd = activeThread?.worktreePath ?? activeProject.cwd;5454 void api.shell.openInEditor(openInCwd, editor);
5460 void api.shell.openInEditor(cwd, editor);
5461 localStorage.setItem(LAST_EDITOR_KEY, editor);5455 localStorage.setItem(LAST_EDITOR_KEY, editor);
5462 setLastEditor(editor);5456 setLastEditor(editor);
5463 },5457 },
5464 [activeProject, activeThread, effectiveEditor, setLastEditor],5458 [effectiveEditor, openInCwd, setLastEditor],
5465 );5459 );
54665460
5467 const openFavoriteEditorShortcutLabel = useMemo(5461 const openFavoriteEditorShortcutLabel = useMemo(
...@@ -5473,23 +5467,22 @@ const OpenInPicker = memo(function OpenInPicker({...@@ -5473,23 +5467,22 @@ const OpenInPicker = memo(function OpenInPicker({
5473 const handler = (e: globalThis.KeyboardEvent) => {5467 const handler = (e: globalThis.KeyboardEvent) => {
5474 const api = readNativeApi();5468 const api = readNativeApi();
5475 if (!isOpenFavoriteEditorShortcut(e, keybindings)) return;5469 if (!isOpenFavoriteEditorShortcut(e, keybindings)) return;
5476 if (!api || !activeProject) return;5470 if (!api || !openInCwd) return;
5477 if (!effectiveEditor) return;5471 if (!effectiveEditor) return;
54785472
5479 e.preventDefault();5473 e.preventDefault();
5480 const cwd = activeThread?.worktreePath ?? activeProject.cwd;5474 void api.shell.openInEditor(openInCwd, effectiveEditor);
5481 void api.shell.openInEditor(cwd, effectiveEditor);
5482 };5475 };
5483 window.addEventListener("keydown", handler);5476 window.addEventListener("keydown", handler);
5484 return () => window.removeEventListener("keydown", handler);5477 return () => window.removeEventListener("keydown", handler);
5485 }, [activeProject, activeThread, keybindings, effectiveEditor]);5478 }, [effectiveEditor, keybindings, openInCwd]);
54865479
5487 return (5480 return (
5488 <Group aria-label="Subscription actions">5481 <Group aria-label="Subscription actions">
5489 <Button5482 <Button
5490 size="xs"5483 size="xs"
5491 variant="outline"5484 variant="outline"
5492 disabled={!effectiveEditor || !activeProject}5485 disabled={!effectiveEditor || !openInCwd}
5493 onClick={() => openInEditor(effectiveEditor)}5486 onClick={() => openInEditor(effectiveEditor)}
5494 >5487 >
5495 {primaryOption?.Icon && <primaryOption.Icon aria-hidden="true" className="size-3.5" />}5488 {primaryOption?.Icon && <primaryOption.Icon aria-hidden="true" className="size-3.5" />}