| author | |
| committer | |
| log | c63ea8d25b4d05d32fc061736bc6a0934ac682fb |
| tree | ace1b9770d63b2ef353d94679fdcf816e2cd6d3b |
| parent | b9e529e0b56b995728ff10cd1b643df63b436258 |
- 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 snapshot2 files changed, 90 insertions(+), 20 deletions(-)
apps/web/src/components/ChatView.browser.tsx+77| ... | ... | @@ -19,6 +19,7 @@ import { page } from "vitest/browser"; |
| 19 | 19 | import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; |
| 20 | 20 | import { render } from "vitest-browser-react"; |
| 21 | 21 | |
| 22 | import { useComposerDraftStore } from "../composerDraftStore"; | |
| 22 | 23 | import { getRouter } from "../router"; |
| 23 | 24 | import { useStore } from "../store"; |
| 24 | 25 | import { estimateTimelineMessageHeight } from "./timelineHeight"; |
| ... | ... | @@ -44,6 +45,7 @@ interface TestFixture { |
| 44 | 45 | } |
| 45 | 46 | |
| 46 | 47 | let fixture: TestFixture; |
| 48 | const wsRequests: WsRequestEnvelope["body"][] = []; | |
| 47 | 49 | const wsLink = ws.link(/ws(s)?:\/\/.*/); |
| 48 | 50 | |
| 49 | 51 | interface ViewportSpec { |
| ... | ... | @@ -243,6 +245,17 @@ function buildFixture(snapshot: OrchestrationReadModel): TestFixture { |
| 243 | 245 | }; |
| 244 | 246 | } |
| 245 | 247 | |
| 248 | function 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 | ||
| 246 | 259 | function resolveWsRpc(tag: string): unknown { |
| 247 | 260 | if (tag === ORCHESTRATION_WS_METHODS.getSnapshot) { |
| 248 | 261 | return fixture.snapshot; |
| ... | ... | @@ -307,6 +320,7 @@ const worker = setupWorker( |
| 307 | 320 | } |
| 308 | 321 | const method = request.body?._tag; |
| 309 | 322 | if (typeof method !== "string") return; |
| 323 | wsRequests.push(request.body); | |
| 310 | 324 | client.send( |
| 311 | 325 | JSON.stringify({ |
| 312 | 326 | id: request.id, |
| ... | ... | @@ -466,8 +480,10 @@ async function measureUserRow(options: { |
| 466 | 480 | async function mountChatView(options: { |
| 467 | 481 | viewport: ViewportSpec; |
| 468 | 482 | snapshot: OrchestrationReadModel; |
| 483 | configureFixture?: (fixture: TestFixture) => void; | |
| 469 | 484 | }): Promise<MountedChatView> { |
| 470 | 485 | fixture = buildFixture(options.snapshot); |
| 486 | options.configureFixture?.(fixture); | |
| 471 | 487 | await setViewport(options.viewport); |
| 472 | 488 | await waitForProductionStyles(); |
| 473 | 489 | |
| ... | ... | @@ -547,6 +563,12 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 547 | 563 | await setViewport(DEFAULT_VIEWPORT); |
| 548 | 564 | localStorage.clear(); |
| 549 | 565 | document.body.innerHTML = ""; |
| 566 | wsRequests.length = 0; | |
| 567 | useComposerDraftStore.setState({ | |
| 568 | draftsByThreadId: {}, | |
| 569 | draftThreadsByThreadId: {}, | |
| 570 | projectDraftThreadIdByProjectId: {}, | |
| 571 | }); | |
| 550 | 572 | useStore.setState({ |
| 551 | 573 | projects: [], |
| 552 | 574 | threads: [], |
| ... | ... | @@ -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 | 3293 | activeThreadId={activeThread.id} |
| 3294 | 3294 | activeThreadTitle={activeThread.title} |
| 3295 | 3295 | activeProjectName={activeProject?.name} |
| 3296 | openInCwd={activeThread.worktreePath ?? activeProject?.cwd ?? null} | |
| 3296 | 3297 | activeProjectScripts={activeProject?.scripts} |
| 3297 | 3298 | preferredScriptId={ |
| 3298 | 3299 | activeProject ? (lastInvokedScriptByProjectId[activeProject.id] ?? null) : null |
| ... | ... | @@ -3855,6 +3856,7 @@ interface ChatHeaderProps { |
| 3855 | 3856 | activeThreadId: ThreadId; |
| 3856 | 3857 | activeThreadTitle: string; |
| 3857 | 3858 | activeProjectName: string | undefined; |
| 3859 | openInCwd: string | null; | |
| 3858 | 3860 | activeProjectScripts: ProjectScript[] | undefined; |
| 3859 | 3861 | preferredScriptId: string | null; |
| 3860 | 3862 | keybindings: ResolvedKeybindingsConfig; |
| ... | ... | @@ -3872,6 +3874,7 @@ const ChatHeader = memo(function ChatHeader({ |
| 3872 | 3874 | activeThreadId, |
| 3873 | 3875 | activeThreadTitle, |
| 3874 | 3876 | activeProjectName, |
| 3877 | openInCwd, | |
| 3875 | 3878 | activeProjectScripts, |
| 3876 | 3879 | preferredScriptId, |
| 3877 | 3880 | keybindings, |
| ... | ... | @@ -3915,7 +3918,7 @@ const ChatHeader = memo(function ChatHeader({ |
| 3915 | 3918 | <OpenInPicker |
| 3916 | 3919 | keybindings={keybindings} |
| 3917 | 3920 | availableEditors={availableEditors} |
| 3918 | activeThreadId={activeThreadId} | |
| 3921 | openInCwd={openInCwd} | |
| 3919 | 3922 | /> |
| 3920 | 3923 | )} |
| 3921 | 3924 | {activeProjectName && <GitActionsControl gitCwd={gitCwd} activeThreadId={activeThreadId} />} |
| ... | ... | @@ -5392,11 +5395,11 @@ const CodexTraitsPicker = memo(function CodexTraitsPicker(props: { |
| 5392 | 5395 | const OpenInPicker = memo(function OpenInPicker({ |
| 5393 | 5396 | keybindings, |
| 5394 | 5397 | availableEditors, |
| 5395 | activeThreadId, | |
| 5398 | openInCwd, | |
| 5396 | 5399 | }: { |
| 5397 | 5400 | keybindings: ResolvedKeybindingsConfig; |
| 5398 | 5401 | availableEditors: ReadonlyArray<EditorId>; |
| 5399 | activeThreadId: ThreadId | null; | |
| 5402 | openInCwd: string | null; | |
| 5400 | 5403 | }) { |
| 5401 | 5404 | const [lastEditor, setLastEditor] = useState<EditorId>(() => { |
| 5402 | 5405 | const stored = localStorage.getItem(LAST_EDITOR_KEY); |
| ... | ... | @@ -5442,26 +5445,17 @@ const OpenInPicker = memo(function OpenInPicker({ |
| 5442 | 5445 | : (options[0]?.value ?? null); |
| 5443 | 5446 | const primaryOption = options.find(({ value }) => value === effectiveEditor) ?? null; |
| 5444 | 5447 | |
| 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 | 5448 | const openInEditor = useCallback( |
| 5454 | 5449 | (editorId: EditorId | null) => { |
| 5455 | 5450 | const api = readNativeApi(); |
| 5456 | if (!api || !activeProject) return; | |
| 5451 | if (!api || !openInCwd) return; | |
| 5457 | 5452 | const editor = editorId ?? effectiveEditor; |
| 5458 | 5453 | if (!editor) return; |
| 5459 | const cwd = activeThread?.worktreePath ?? activeProject.cwd; | |
| 5460 | void api.shell.openInEditor(cwd, editor); | |
| 5454 | void api.shell.openInEditor(openInCwd, editor); | |
| 5461 | 5455 | localStorage.setItem(LAST_EDITOR_KEY, editor); |
| 5462 | 5456 | setLastEditor(editor); |
| 5463 | 5457 | }, |
| 5464 | [activeProject, activeThread, effectiveEditor, setLastEditor], | |
| 5458 | [effectiveEditor, openInCwd, setLastEditor], | |
| 5465 | 5459 | ); |
| 5466 | 5460 | |
| 5467 | 5461 | const openFavoriteEditorShortcutLabel = useMemo( |
| ... | ... | @@ -5473,23 +5467,22 @@ const OpenInPicker = memo(function OpenInPicker({ |
| 5473 | 5467 | const handler = (e: globalThis.KeyboardEvent) => { |
| 5474 | 5468 | const api = readNativeApi(); |
| 5475 | 5469 | if (!isOpenFavoriteEditorShortcut(e, keybindings)) return; |
| 5476 | if (!api || !activeProject) return; | |
| 5470 | if (!api || !openInCwd) return; | |
| 5477 | 5471 | if (!effectiveEditor) return; |
| 5478 | 5472 | |
| 5479 | 5473 | e.preventDefault(); |
| 5480 | const cwd = activeThread?.worktreePath ?? activeProject.cwd; | |
| 5481 | void api.shell.openInEditor(cwd, effectiveEditor); | |
| 5474 | void api.shell.openInEditor(openInCwd, effectiveEditor); | |
| 5482 | 5475 | }; |
| 5483 | 5476 | window.addEventListener("keydown", handler); |
| 5484 | 5477 | return () => window.removeEventListener("keydown", handler); |
| 5485 | }, [activeProject, activeThread, keybindings, effectiveEditor]); | |
| 5478 | }, [effectiveEditor, keybindings, openInCwd]); | |
| 5486 | 5479 | |
| 5487 | 5480 | return ( |
| 5488 | 5481 | <Group aria-label="Subscription actions"> |
| 5489 | 5482 | <Button |
| 5490 | 5483 | size="xs" |
| 5491 | 5484 | variant="outline" |
| 5492 | disabled={!effectiveEditor || !activeProject} | |
| 5485 | disabled={!effectiveEditor || !openInCwd} | |
| 5493 | 5486 | onClick={() => openInEditor(effectiveEditor)} |
| 5494 | 5487 | > |
| 5495 | 5488 | {primaryOption?.Icon && <primaryOption.Icon aria-hidden="true" className="size-3.5" />} |