diff --git a/apps/desktop/src/app/chat/composer/status-stack/preview-row.tsx b/apps/desktop/src/app/chat/composer/status-stack/preview-row.tsx index 510d0f534c..8e2eedf613 100644 --- a/apps/desktop/src/app/chat/composer/status-stack/preview-row.tsx +++ b/apps/desktop/src/app/chat/composer/status-stack/preview-row.tsx @@ -9,7 +9,7 @@ import { isDesktopFsRemoteMode } from '@/lib/desktop-fs' import { normalizeOrLocalPreviewTarget, openPreviewTargetInBrowser } from '@/lib/local-preview' import { cn } from '@/lib/utils' import { notifyError } from '@/store/notifications' -import { $previewTabSources, closePreviewForSource, openPreview } from '@/store/preview' +import { $previewTabSources, closePreviewForSource, openPreview, renderedHtmlTarget } from '@/store/preview' import { type PreviewArtifact } from '@/store/preview-status' interface PreviewStatusRowProps { @@ -49,7 +49,7 @@ export const PreviewStatusRow = memo(function PreviewStatusRow({ item, onDismiss setOpening(true) try { - openPreview(await resolveTarget()) + openPreview(renderedHtmlTarget(await resolveTarget())) } catch (error) { notifyError(error, t.preview.unavailable) } finally { diff --git a/apps/desktop/src/app/chat/right-rail/preview-pane.tsx b/apps/desktop/src/app/chat/right-rail/preview-pane.tsx index 8c0ef0a46d..b93e06fa8f 100644 --- a/apps/desktop/src/app/chat/right-rail/preview-pane.tsx +++ b/apps/desktop/src/app/chat/right-rail/preview-pane.tsx @@ -34,11 +34,11 @@ import { notify, notifyError } from '@/store/notifications' import { $browserPages, $previewServerRestart, - $previewTabs, commitBrowserTabLocation, failPreviewServerRestart, noteBrowserPage, popOutBrowserTab, + type PreviewRenderMode, type PreviewTarget, setPreviewRenderMode } from '@/store/preview' @@ -251,7 +251,6 @@ export function PreviewPane({ embedded = false, onRestartServer, reloadRequest = const previewContentRef = useRef(null) const webviewRef = useRef(null) const previewServerRestart = useStore($previewServerRestart) - const previewTabs = useStore($previewTabs) const consoleHeight = useStore(consoleState.$height) const consoleOpen = useStore(consoleState.$open) const selectedStoredSessionId = useStore($selectedStoredSessionId) @@ -263,7 +262,6 @@ export function PreviewPane({ embedded = false, onRestartServer, reloadRequest = const [loading, setLoading] = useState(true) const [loadError, setLoadError] = useState(null) const [localReloadKey, setLocalReloadKey] = useState(0) - const [localRenderMode, setLocalRenderMode] = useState<'preview' | 'source' | undefined>() const [annotate, setAnnotate] = useState(emptyAnnotateSession) const [draftNote, setDraftNote] = useState('') const annotateRef = useRef(annotate) @@ -271,9 +269,7 @@ export function PreviewPane({ embedded = false, onRestartServer, reloadRequest = const annotateConversationRef = useRef(selectedStoredSessionId) annotateRef.current = annotate - const liveTarget = (tabId && previewTabs.find(tab => tab.id === tabId)?.target) || target - const renderMode = tabId ? liveTarget.renderMode : (localRenderMode ?? target.renderMode) - const isHtmlFileTarget = target.kind === 'file' && target.previewKind === 'html' + const renderMode = target.renderMode // Artifacts have no URL to load — they render from the registry, never in a // webview. @@ -283,15 +279,18 @@ export function PreviewPane({ embedded = false, onRestartServer, reloadRequest = const isRemoteHtmlTarget = target.kind === 'file' && target.previewKind === 'html' && Boolean(target.dataUrl || target.transient) - // A remote HTML file whose data URL failed validation arrives as a - // source-only transient target; it has no rendered path to offer. - const canRenderHtmlFile = isHtmlFileTarget && (!isRemoteHtmlTarget || Boolean(target.dataUrl)) + // The mode lives on the store tab, so only a tab-backed pane can flip it. A + // remote HTML file whose data URL failed validation arrives as a source-only + // transient target; it has no rendered path to offer. + const canRenderHtmlFile = + Boolean(tabId) && + target.kind === 'file' && + target.previewKind === 'html' && + (!target.transient || Boolean(target.dataUrl)) - const selectRenderMode = (next: 'preview' | 'source') => { + const selectRenderMode = (next: PreviewRenderMode) => { if (tabId) { setPreviewRenderMode(tabId, next) - } else { - setLocalRenderMode(next) } } @@ -1405,7 +1404,7 @@ export function PreviewPane({ embedded = false, onRestartServer, reloadRequest = )} - {loadError && ( + {isWebPreview && loadError && ( )} - {annotate.draft ? ( + {isWebPreview && annotate.draft ? ( { expect($previewTarget.get()?.renderMode).toBe('preview') }) + it('renders an agent hand-over of an HTML file even when its tab sits in Source', () => { + const target = fileTarget('/work/handed.html') + + openPreview(target) + setPreviewRenderMode(previewTabId(target), 'source') + openPreview(renderedHtmlTarget(target)) + + expect($previewTabs.get()).toHaveLength(1) + expect($previewTarget.get()?.renderMode).toBe('preview') + + // An explicit mode and non-HTML targets pass through untouched. + expect(renderedHtmlTarget({ ...target, renderMode: 'source' }).renderMode).toBe('source') + expect(renderedHtmlTarget({ ...fileTarget('/work/notes.md'), previewKind: 'text' }).renderMode).toBeUndefined() + }) + it('falls back to a neighbouring tab when the active one closes, and clears the selection on the last', () => { openPreview(fileTarget('/work/one.html')) openPreview(fileTarget('/work/two.html')) diff --git a/apps/desktop/src/store/preview.ts b/apps/desktop/src/store/preview.ts index 3823ad7e7b..c36aee8777 100644 --- a/apps/desktop/src/store/preview.ts +++ b/apps/desktop/src/store/preview.ts @@ -20,6 +20,9 @@ import { canOpenBrowserWindow, openBrowserInNewWindow } from './windows' * anywhere else — they close when you close them. */ +/** How an HTML file target shows: the live page, or its source. */ +export type PreviewRenderMode = 'preview' | 'source' + export interface PreviewTarget { binary?: boolean byteSize?: number @@ -38,7 +41,7 @@ export interface PreviewTarget { mimeType?: string path?: string previewKind?: 'binary' | 'html' | 'image' | 'pdf' | 'text' - renderMode?: 'preview' | 'source' + renderMode?: PreviewRenderMode source: string /** Runtime-only target that cannot be restored from persisted state. */ transient?: boolean @@ -503,16 +506,24 @@ function browserTabId(tabs: PreviewTab[]): RightRailTabId { /** HTML files open rendered unless the caller asks for a mode. A re-open keeps * the mode the tab is already in, so refreshing the target never undoes a * user's Source pick. */ -function withRenderMode(target: PreviewTarget, open?: PreviewTarget): PreviewTarget { +function withRenderMode(target: PreviewTarget, existing?: PreviewTarget): PreviewTarget { if (target.kind !== 'file' || target.previewKind !== 'html' || target.renderMode) { return target } - return { ...target, renderMode: open?.renderMode ?? 'preview' } + return { ...target, renderMode: existing?.renderMode ?? 'preview' } +} + +/** An agent hand-over means "show the page": an HTML file opens rendered even + * when its tab is sitting in Source, unlike a re-open from the Files pane. */ +export function renderedHtmlTarget(target: PreviewTarget): PreviewTarget { + return target.kind === 'file' && target.previewKind === 'html' && !target.renderMode + ? { ...target, renderMode: 'preview' } + : target } /** Flip a tab between live Render and Source in place. Same tab id. */ -export function setPreviewRenderMode(tabId: string, renderMode: 'preview' | 'source') { +export function setPreviewRenderMode(tabId: string, renderMode: PreviewRenderMode) { const current = $previewTabs.get() const index = current.findIndex(tab => tab.id === tabId)