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";
1919import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
2020import { render } from "vitest-browser-react";
2121
22import { useComposerDraftStore } from "../composerDraftStore";
2223import { getRouter } from "../router";
2324import { useStore } from "../store";
2425import { estimateTimelineMessageHeight } from "./timelineHeight";
......@@ -44,6 +45,7 @@ interface TestFixture {
4445}
4546
4647let fixture: TestFixture;
48const wsRequests: WsRequestEnvelope["body"][] = [];
4749const wsLink = ws.link(/ws(s)?:\/\/.*/);
4850
4951interface ViewportSpec {
......@@ -243,6 +245,17 @@ function buildFixture(snapshot: OrchestrationReadModel): TestFixture {
243245 };
244246}
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
246259function resolveWsRpc(tag: string): unknown {
247260 if (tag === ORCHESTRATION_WS_METHODS.getSnapshot) {
248261 return fixture.snapshot;
......@@ -307,6 +320,7 @@ const worker = setupWorker(
307320 }
308321 const method = request.body?._tag;
309322 if (typeof method !== "string") return;
323 wsRequests.push(request.body);
310324 client.send(
311325 JSON.stringify({
312326 id: request.id,
......@@ -466,8 +480,10 @@ async function measureUserRow(options: {
466480async function mountChatView(options: {
467481 viewport: ViewportSpec;
468482 snapshot: OrchestrationReadModel;
483 configureFixture?: (fixture: TestFixture) => void;
469484}): Promise<MountedChatView> {
470485 fixture = buildFixture(options.snapshot);
486 options.configureFixture?.(fixture);
471487 await setViewport(options.viewport);
472488 await waitForProductionStyles();
473489
......@@ -547,6 +563,12 @@ describe("ChatView timeline estimator parity (full app)", () => {
547563 await setViewport(DEFAULT_VIEWPORT);
548564 localStorage.clear();
549565 document.body.innerHTML = "";
566 wsRequests.length = 0;
567 useComposerDraftStore.setState({
568 draftsByThreadId: {},
569 draftThreadsByThreadId: {},
570 projectDraftThreadIdByProjectId: {},
571 });
550572 useStore.setState({
551573 projects: [],
552574 threads: [],
......@@ -708,4 +730,59 @@ describe("ChatView timeline estimator parity (full app)", () => {
708730 }
709731 },
710732 );
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 });
711788});
apps/web/src/components/ChatView.tsx+13-20
......@@ -3293,6 +3293,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
32933293 activeThreadId={activeThread.id}
32943294 activeThreadTitle={activeThread.title}
32953295 activeProjectName={activeProject?.name}
3296 openInCwd={activeThread.worktreePath ?? activeProject?.cwd ?? null}
32963297 activeProjectScripts={activeProject?.scripts}
32973298 preferredScriptId={
32983299 activeProject ? (lastInvokedScriptByProjectId[activeProject.id] ?? null) : null
......@@ -3855,6 +3856,7 @@ interface ChatHeaderProps {
38553856 activeThreadId: ThreadId;
38563857 activeThreadTitle: string;
38573858 activeProjectName: string | undefined;
3859 openInCwd: string | null;
38583860 activeProjectScripts: ProjectScript[] | undefined;
38593861 preferredScriptId: string | null;
38603862 keybindings: ResolvedKeybindingsConfig;
......@@ -3872,6 +3874,7 @@ const ChatHeader = memo(function ChatHeader({
38723874 activeThreadId,
38733875 activeThreadTitle,
38743876 activeProjectName,
3877 openInCwd,
38753878 activeProjectScripts,
38763879 preferredScriptId,
38773880 keybindings,
......@@ -3915,7 +3918,7 @@ const ChatHeader = memo(function ChatHeader({
39153918 <OpenInPicker
39163919 keybindings={keybindings}
39173920 availableEditors={availableEditors}
3918 activeThreadId={activeThreadId}
3921 openInCwd={openInCwd}
39193922 />
39203923 )}
39213924 {activeProjectName && <GitActionsControl gitCwd={gitCwd} activeThreadId={activeThreadId} />}
......@@ -5392,11 +5395,11 @@ const CodexTraitsPicker = memo(function CodexTraitsPicker(props: {
53925395const OpenInPicker = memo(function OpenInPicker({
53935396 keybindings,
53945397 availableEditors,
5395 activeThreadId,
5398 openInCwd,
53965399}: {
53975400 keybindings: ResolvedKeybindingsConfig;
53985401 availableEditors: ReadonlyArray<EditorId>;
5399 activeThreadId: ThreadId | null;
5402 openInCwd: string | null;
54005403}) {
54015404 const [lastEditor, setLastEditor] = useState<EditorId>(() => {
54025405 const stored = localStorage.getItem(LAST_EDITOR_KEY);
......@@ -5442,26 +5445,17 @@ const OpenInPicker = memo(function OpenInPicker({
54425445 : (options[0]?.value ?? null);
54435446 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
54535448 const openInEditor = useCallback(
54545449 (editorId: EditorId | null) => {
54555450 const api = readNativeApi();
5456 if (!api || !activeProject) return;
5451 if (!api || !openInCwd) return;
54575452 const editor = editorId ?? effectiveEditor;
54585453 if (!editor) return;
5459 const cwd = activeThread?.worktreePath ?? activeProject.cwd;
5460 void api.shell.openInEditor(cwd, editor);
5454 void api.shell.openInEditor(openInCwd, editor);
54615455 localStorage.setItem(LAST_EDITOR_KEY, editor);
54625456 setLastEditor(editor);
54635457 },
5464 [activeProject, activeThread, effectiveEditor, setLastEditor],
5458 [effectiveEditor, openInCwd, setLastEditor],
54655459 );
54665460
54675461 const openFavoriteEditorShortcutLabel = useMemo(
......@@ -5473,23 +5467,22 @@ const OpenInPicker = memo(function OpenInPicker({
54735467 const handler = (e: globalThis.KeyboardEvent) => {
54745468 const api = readNativeApi();
54755469 if (!isOpenFavoriteEditorShortcut(e, keybindings)) return;
5476 if (!api || !activeProject) return;
5470 if (!api || !openInCwd) return;
54775471 if (!effectiveEditor) return;
54785472
54795473 e.preventDefault();
5480 const cwd = activeThread?.worktreePath ?? activeProject.cwd;
5481 void api.shell.openInEditor(cwd, effectiveEditor);
5474 void api.shell.openInEditor(openInCwd, effectiveEditor);
54825475 };
54835476 window.addEventListener("keydown", handler);
54845477 return () => window.removeEventListener("keydown", handler);
5485 }, [activeProject, activeThread, keybindings, effectiveEditor]);
5478 }, [effectiveEditor, keybindings, openInCwd]);
54865479
54875480 return (
54885481 <Group aria-label="Subscription actions">
54895482 <Button
54905483 size="xs"
54915484 variant="outline"
5492 disabled={!effectiveEditor || !activeProject}
5485 disabled={!effectiveEditor || !openInCwd}
54935486 onClick={() => openInEditor(effectiveEditor)}
54945487 >
54955488 {primaryOption?.Icon && <primaryOption.Icon aria-hidden="true" className="size-3.5" />}