From fc2739720536a4c3df98f0874d30ed935e72da19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Sat, 13 Jun 2026 11:01:46 +0800 Subject: [PATCH] fix(desktop): anchor output chips and previews on real changed files Fix four root causes in the desktop preview pipeline, surfaced when the model writes the files the user pointed it at: - Output chips guessed paths from prose and could point at a missing file. They are now reconciled against the turn's real changed files: a bare `index.html` resolves to the `todo-app/index.html` actually written, and mentions the turn never changed are dropped. - A standalone single-page index.html got no browser preview (mistaken for a Vite template). It is now only routed to the source view when a package.json/vite.config ships in the same change-set. - Files written outside the session workdir (another folder, or another drive on Windows) failed to preview with 'Path is outside workspace'. The turn's changed-file directories are registered as filesystem access roots; html serves via /local-file and other files via a workdir-relaxed read. - The visual-selection prompt leaked as a raw bubble on Windows because the server-appended '[Image source: ...]' line broke replay dedupe. Replay text is now metadata-normalized before comparison (affects any image message). Adds unit tests for each: htmlPreviewPolicy, assistantOutputTargets reconciliation, replay dedupe + stripGeneratedImageMetadataLines, filesystem access roots, and workspace outside-workdir reads. --- .../src/components/chat/AssistantMessage.tsx | 9 +- .../chat/CurrentTurnChangeCard.test.tsx | 38 +++++++- .../components/chat/CurrentTurnChangeCard.tsx | 23 ++++- desktop/src/components/chat/MessageList.tsx | 42 ++++++++ .../src/lib/assistantOutputTargets.test.ts | 78 +++++++++++++++ desktop/src/lib/assistantOutputTargets.ts | 96 +++++++++++++++++++ desktop/src/lib/handlePreviewLink.test.ts | 12 ++- desktop/src/lib/htmlPreviewPolicy.test.ts | 82 ++++++++++++++++ desktop/src/lib/htmlPreviewPolicy.ts | 76 +++++++++++++-- .../src/lib/openWithContextForHref.test.ts | 50 +++++++++- desktop/src/lib/openWithContextForHref.ts | 24 ++++- desktop/src/stores/chatStore.test.ts | 81 ++++++++++++++++ desktop/src/stores/chatStore.ts | 31 +++++- .../__tests__/workspace-service.test.ts | 41 ++++++++ src/server/api/sessions.ts | 10 +- .../services/filesystemAccessRoots.test.ts | 37 +++++++ src/server/services/filesystemAccessRoots.ts | 22 +++++ src/server/services/workspaceService.ts | 36 +++++++ 18 files changed, 760 insertions(+), 28 deletions(-) create mode 100644 desktop/src/lib/htmlPreviewPolicy.test.ts create mode 100644 src/server/services/filesystemAccessRoots.test.ts diff --git a/desktop/src/components/chat/AssistantMessage.tsx b/desktop/src/components/chat/AssistantMessage.tsx index aa958abd..a6b5ac5c 100644 --- a/desktop/src/components/chat/AssistantMessage.tsx +++ b/desktop/src/components/chat/AssistantMessage.tsx @@ -19,11 +19,14 @@ type Props = { branchAction?: MessageBranchAction sessionId?: string timestamp?: number + /** This turn's real changed files (absolute), used to anchor output chips onto + * files that were actually written instead of guessing from the prose. */ + turnChangedFiles?: string[] } const MAX_CARDS = 3 -export const AssistantMessage = memo(function AssistantMessage({ content, isStreaming, branchAction, sessionId, timestamp }: Props) { +export const AssistantMessage = memo(function AssistantMessage({ content, isStreaming, branchAction, sessionId, timestamp, turnChangedFiles }: Props) { const t = useTranslation() const workDir = useWorkspacePanelStore((s) => (sessionId ? s.statusBySession[sessionId]?.workDir : undefined)) @@ -53,10 +56,10 @@ export const AssistantMessage = memo(function AssistantMessage({ content, isStre isStreaming || !sessionId ? [] : // Image/video targets render inline (InlineImageGallery/InlineVideoGallery); never also as a card. - extractAssistantOutputTargets(content, { workDir }).filter( + extractAssistantOutputTargets(content, { workDir, changedFiles: turnChangedFiles }).filter( (target) => target.kind !== 'image' && target.kind !== 'video', ), - [content, isStreaming, sessionId, workDir], + [content, isStreaming, sessionId, workDir, turnChangedFiles], ) if (!content.trim()) return null diff --git a/desktop/src/components/chat/CurrentTurnChangeCard.test.tsx b/desktop/src/components/chat/CurrentTurnChangeCard.test.tsx index f0733abe..2969d82d 100644 --- a/desktop/src/components/chat/CurrentTurnChangeCard.test.tsx +++ b/desktop/src/components/chat/CurrentTurnChangeCard.test.tsx @@ -84,6 +84,7 @@ vi.mock('../../i18n', () => ({ // Import after mocks // ────────────────────────────────────────────────────────────────────────────── import { CurrentTurnChangeCard } from './CurrentTurnChangeCard' +import { localFileUrl } from '../../lib/handlePreviewLink' import type { SessionTurnCheckpoint } from '../../api/sessions' // ────────────────────────────────────────────────────────────────────────────── @@ -201,6 +202,24 @@ describe('CurrentTurnChangeCard – row opens the workspace diff', () => { expect(openPreviewSpy).toHaveBeenCalledWith('s1', 'README.md', 'diff') }) + it('clicking an outside-workspace html changed file opens the in-app browser via local-file', () => { + // The file lives outside the workdir (absolute displayPath) — no diff baseline, + // so html renders directly in the in-app browser via the /local-file route. + renderCard(['/other/place/todo.html']) + const row = screen.getByRole('button', { name: /turnChangesOpenInWorkspaceAria/ }) + fireEvent.click(row) + expect(browserOpenSpy).toHaveBeenCalledWith('s1', localFileUrl('http://127.0.0.1:4321', '/other/place/todo.html')) + expect(openPreviewSpy).not.toHaveBeenCalled() + }) + + it('clicking an outside-workspace non-html changed file opens a file preview (not a diff)', () => { + renderCard(['/other/place/notes.txt']) + const row = screen.getByRole('button', { name: /turnChangesOpenInWorkspaceAria/ }) + fireEvent.click(row) + expect(openPreviewSpy).toHaveBeenCalledWith('s1', '/other/place/notes.txt', 'file') + expect(browserOpenSpy).not.toHaveBeenCalled() + }) + it('does NOT render an inline diff surface after clicking a row', () => { renderCard(['/w/proj/src/main.ts']) const row = screen.getByRole('button', { name: /turnChangesOpenInWorkspaceAria/ }) @@ -280,7 +299,9 @@ describe('CurrentTurnChangeCard – open-with buttons', () => { expect(openPreviewSpy).toHaveBeenCalledWith('s1', 'README.md', 'file') }) - it('clicking project index.html open-with opens menu with workspace preview item', async () => { + it('clicking a standalone index.html (no manifest in change-set) offers both workspace preview and in-app browser', async () => { + // A hand-authored single-page index.html is statically previewable, so the + // menu offers the in-app browser alongside the workspace source view. renderCard(['/w/proj/index.html']) const [openWithBtn] = screen.getAllByRole('button', { name: 'openWith.title' }) @@ -288,6 +309,21 @@ describe('CurrentTurnChangeCard – open-with buttons', () => { fireEvent.click(openWithBtn!) }) + expect(await screen.findByText('openWith.workspacePreview')).toBeInTheDocument() + expect(screen.queryByText('openWith.inAppBrowser')).toBeInTheDocument() + }) + + it('clicking a framework-template index.html (manifest in same change-set) hides the in-app browser', async () => { + // With a package.json in the same turn, the root index.html is a build + // template that needs a dev server — static preview would render blank — so + // only the workspace source view is offered. + renderCard(['/w/proj/index.html', '/w/proj/package.json', '/w/proj/vite.config.ts']) + const [openWithBtn] = screen.getAllByRole('button', { name: 'openWith.title' }) + + await act(async () => { + fireEvent.click(openWithBtn!) + }) + expect(await screen.findByText('openWith.workspacePreview')).toBeInTheDocument() expect(screen.queryByText('openWith.inAppBrowser')).not.toBeInTheDocument() }) diff --git a/desktop/src/components/chat/CurrentTurnChangeCard.tsx b/desktop/src/components/chat/CurrentTurnChangeCard.tsx index 28dc3a0b..896d3c7e 100644 --- a/desktop/src/components/chat/CurrentTurnChangeCard.tsx +++ b/desktop/src/components/chat/CurrentTurnChangeCard.tsx @@ -6,6 +6,8 @@ import { useTranslation, type TranslationKey } from '../../i18n' import { OpenWithMenu } from '../common/OpenWithMenu' import { buildOpenWithItems, describeFileType, isPreviewableChangedFile, type OpenWithItem } from '../../lib/openWithItems' import { openWithContextForWorkspaceFile } from '../../lib/openWithContextForHref' +import { isAbsoluteLocalPath, localFileUrl } from '../../lib/handlePreviewLink' +import { shouldOfferStaticHtmlPreview } from '../../lib/htmlPreviewPolicy' import { getServerBaseUrl } from '../../lib/desktopRuntime' import { getDesktopHost } from '../../lib/desktopHost' import { useOpenTargetStore } from '../../stores/openTargetStore' @@ -57,12 +59,24 @@ export function CurrentTurnChangeCard({ ? files.slice(0, COLLAPSED_COUNT) : files - const openDiffInWorkspace = useCallback((fileEntry: ChangedFileEntry) => { + const openChangedFile = useCallback((fileEntry: ChangedFileEntry) => { + // A changed file outside the workdir (absolute displayPath — e.g. another + // drive) has no checkpoint baseline, so a diff is meaningless. Render html in + // the in-app browser and everything else as a file preview (served by its + // absolute path). In-workdir files keep the diff view. + if (isAbsoluteLocalPath(fileEntry.displayPath)) { + if (shouldOfferStaticHtmlPreview(fileEntry.displayPath, { siblingFiles: files.map((entry) => entry.displayPath) })) { + useBrowserPanelStore.getState().open(sessionId, localFileUrl(getServerBaseUrl(), fileEntry.apiPath)) + return + } + void useWorkspacePanelStore.getState().openPreview(sessionId, fileEntry.displayPath, 'file') + return + } // Jump to the right-side workspace and open a diff tab. We pass the workDir-relative // path (same format the workspace file tree passes to openPreview), so the diff tab // is keyed/fetched identically to the tree-driven one. void useWorkspacePanelStore.getState().openPreview(sessionId, fileEntry.displayPath, 'diff') - }, [sessionId]) + }, [sessionId, files]) const handleOpenWith = useCallback((event: ReactMouseEvent, fileEntry: ChangedFileEntry) => { event.stopPropagation() @@ -81,6 +95,7 @@ export function CurrentTurnChangeCard({ const ctx = openWithContextForWorkspaceFile(fileEntry.displayPath, fileEntry.apiPath, { sessionId, serverBaseUrl: getServerBaseUrl(), + siblingFiles: files.map((entry) => entry.displayPath), }) const items = buildOpenWithItems(ctx, targets, { openInAppBrowser: (url) => useBrowserPanelStore.getState().open(sessionId, url), @@ -91,7 +106,7 @@ export function CurrentTurnChangeCard({ }) setOpenWith({ items, anchor: rect, triggerEl }) })() - }, [openWith, sessionId, t]) + }, [openWith, sessionId, t, files]) const cardLabel = isLatest ? t('chat.turnChangesLatestCardLabel') @@ -150,7 +165,7 @@ export function CurrentTurnChangeCard({