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({