diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index 50b3c7cf117..510405ac366 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -944,6 +944,33 @@ function openExternalUrl(rawUrl) { return true } +async function openPreviewInBrowser(rawUrl) { + const raw = String(rawUrl || '').trim() + if (!raw) return false + + let parsed + try { + parsed = new URL(raw) + } catch { + return false + } + + if (parsed.protocol === 'file:') { + let localPath + try { + localPath = resolveRequestedPathForIpc(parsed.toString(), { purpose: 'Open preview in browser' }) + } catch { + return false + } + + await shell.openExternal(pathToFileURL(localPath).toString()) + + return true + } + + return openExternalUrl(raw) +} + function ensureWslWindowsFonts() { if (!IS_WSL) return @@ -5998,6 +6025,12 @@ ipcMain.handle('hermes:openExternal', (_event, url) => { } }) +ipcMain.handle('hermes:openPreviewInBrowser', async (_event, url) => { + if (!(await openPreviewInBrowser(url))) { + throw new Error('Invalid preview URL') + } +}) + // User-configurable default project directory. The renderer reads this on // settings mount and seeds the value into the picker; writing back persists // it via writeDefaultProjectDir so resolveHermesCwd picks it up on the next diff --git a/apps/desktop/electron/preload.cjs b/apps/desktop/electron/preload.cjs index f033475c544..68f75c7b81f 100644 --- a/apps/desktop/electron/preload.cjs +++ b/apps/desktop/electron/preload.cjs @@ -44,6 +44,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', { setTranslucency: payload => ipcRenderer.send('hermes:translucency', payload), setPreviewShortcutActive: active => ipcRenderer.send('hermes:previewShortcutActive', Boolean(active)), openExternal: url => ipcRenderer.invoke('hermes:openExternal', url), + openPreviewInBrowser: url => ipcRenderer.invoke('hermes:openPreviewInBrowser', url), fetchLinkTitle: url => ipcRenderer.invoke('hermes:fetchLinkTitle', url), sanitizeWorkspaceCwd: cwd => ipcRenderer.invoke('hermes:workspace:sanitize', cwd), settings: { diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index 1ecc76de8bc..4010f2f783e 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -60,6 +60,7 @@ import { updateQueuedPrompt } from '@/store/composer-queue' import { $statusItemsBySession } from '@/store/composer-status' +import { $previewStatusBySession } from '@/store/preview-status' import { notify } from '@/store/notifications' import { $gatewayState, $messages, setSessionPickerOpen } from '@/store/session' import { $threadScrolledUp } from '@/store/thread-scroll' @@ -195,6 +196,7 @@ export function ChatBar({ const attachments = useStore($composerAttachments) const queuedPromptsBySession = useStore($queuedPromptsBySession) const statusItemsBySession = useStore($statusItemsBySession) + const previewStatusBySession = useStore($previewStatusBySession) const scrolledUp = useStore($threadScrolledUp) // Pop-out is a shared, persisted state — but secondary windows (the Ctrl+Shift+N // tiny window, subagent watch windows) always start docked and can't pop out: @@ -217,8 +219,12 @@ export function ChatBar({ const statusStackVisible = useMemo( () => - queuedPrompts.length > 0 || (statusSessionId ? (statusItemsBySession[statusSessionId]?.length ?? 0) > 0 : false), - [queuedPrompts.length, statusItemsBySession, statusSessionId] + queuedPrompts.length > 0 || + (statusSessionId + ? (statusItemsBySession[statusSessionId]?.length ?? 0) > 0 || + (previewStatusBySession[statusSessionId]?.length ?? 0) > 0 + : false), + [previewStatusBySession, queuedPrompts.length, statusItemsBySession, statusSessionId] ) const composerRef = useRef(null) diff --git a/apps/desktop/src/app/chat/composer/status-stack/index.tsx b/apps/desktop/src/app/chat/composer/status-stack/index.tsx index a13e039ecc6..b9cf2ffb99c 100644 --- a/apps/desktop/src/app/chat/composer/status-stack/index.tsx +++ b/apps/desktop/src/app/chat/composer/status-stack/index.tsx @@ -19,9 +19,11 @@ import { type StatusGroup, stopBackgroundProcess } from '@/store/composer-status' +import { $previewStatusBySession, dismissPreviewArtifact } from '@/store/preview-status' import { $threadScrolledUp } from '@/store/thread-scroll' import { openSessionInNewWindow } from '@/store/windows' +import { PreviewStatusRow } from './preview-row' import { StatusItemRow } from './status-row' // Slow safety-net poll for silent exits (processes without notify_on_complete @@ -52,6 +54,7 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro const { t } = useI18n() const navigate = useNavigate() const itemsBySession = useStore($statusItemsBySession) + const previewsBySession = useStore($previewStatusBySession) const scrolledUp = useStore($threadScrolledUp) const groups = useMemo( @@ -59,6 +62,8 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro [itemsBySession, sessionId] ) + const previews = sessionId ? (previewsBySession[sessionId] ?? []) : [] + // Seed from the registry on session open; event-driven refreshes (terminal / // process tool completions) live in use-message-stream. useEffect(() => { @@ -122,6 +127,21 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro ) })) + if (previews.length > 0 && sessionId) { + sections.push({ + key: 'preview', + // Not a collapsible group — preview links just sit there, one line each, + // each individually closeable. + node: ( +
+ {previews.map(item => ( + dismissPreviewArtifact(sessionId, id)} /> + ))} +
+ ) + }) + } + if (queue) { sections.push({ key: 'queue', node: queue }) } 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 new file mode 100644 index 00000000000..cc6893f0e64 --- /dev/null +++ b/apps/desktop/src/app/chat/composer/status-stack/preview-row.tsx @@ -0,0 +1,125 @@ +import { useStore } from '@nanostores/react' +import { memo, useState } from 'react' + +import { StatusRow } from '@/components/chat/status-row' +import { Button } from '@/components/ui/button' +import { Codicon } from '@/components/ui/codicon' +import { Tip } from '@/components/ui/tooltip' +import { useI18n } from '@/i18n' +import { ChevronRight, X } from '@/lib/icons' +import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview' +import { cn } from '@/lib/utils' +import { PREVIEW_PANE_ID } from '@/store/layout' +import { notifyError } from '@/store/notifications' +import { $paneOpen } from '@/store/panes' +import { $previewTarget, dismissPreviewTarget, setCurrentSessionPreviewTarget } from '@/store/preview' +import { type PreviewArtifact } from '@/store/preview-status' + +interface PreviewStatusRowProps { + item: PreviewArtifact + onDismiss: (id: string) => void +} + +/** One detected artifact, single line, always visible: filename + open + close. */ +export const PreviewStatusRow = memo(function PreviewStatusRow({ item, onDismiss }: PreviewStatusRowProps) { + const { t } = useI18n() + const activePreview = useStore($previewTarget) + const previewPaneOpen = useStore($paneOpen(PREVIEW_PANE_ID)) + const [opening, setOpening] = useState(false) + const isOpen = activePreview?.source === item.target && previewPaneOpen + + const resolveTarget = async () => { + const target = await normalizeOrLocalPreviewTarget(item.target, item.cwd || undefined) + + if (!target) { + throw new Error(`Could not open preview target: ${item.target}`) + } + + return target + } + + const togglePreview = async () => { + if (opening) { + return + } + + if (isOpen) { + dismissPreviewTarget() + + return + } + + setOpening(true) + + try { + setCurrentSessionPreviewTarget(await resolveTarget(), 'tool-result', item.target) + } catch (error) { + notifyError(error, t.preview.unavailable) + } finally { + setOpening(false) + } + } + + const openInBrowser = async () => { + try { + const bridge = window.hermesDesktop?.openPreviewInBrowser + + if (!bridge) { + throw new Error('Desktop preview browser bridge is unavailable') + } + + await bridge((await resolveTarget()).url) + } catch (error) { + notifyError(error, t.preview.unavailable) + } + } + + return ( + } + onActivate={() => void togglePreview()} + trailing={ + + + + + + + + + } + trailingVisible + > + + {item.label} + + + {opening ? t.preview.opening : isOpen ? t.preview.hide : t.preview.openPreview} + + + ) +}) diff --git a/apps/desktop/src/app/desktop-controller.tsx b/apps/desktop/src/app/desktop-controller.tsx index c8cb9facc13..ced02523d22 100644 --- a/apps/desktop/src/app/desktop-controller.tsx +++ b/apps/desktop/src/app/desktop-controller.tsx @@ -33,6 +33,7 @@ import { FILE_BROWSER_MAX_WIDTH, FILE_BROWSER_MIN_WIDTH, pinSession, + PREVIEW_PANE_ID, setSidebarOverlayMounted, SIDEBAR_DEFAULT_WIDTH, SIDEBAR_MAX_WIDTH, @@ -1077,7 +1078,7 @@ export function DesktopController() { const previewPane = ( { expect(window.hermesDesktop.normalizePreviewTarget).not.toHaveBeenCalled() }) - it('registers structured tool-result preview targets', async () => { - render( - { - handleEvent = handler - }} - /> - ) - - act(() => - handleEvent({ - payload: { path: './dist/index.html' }, - session_id: 'session-1', - type: 'tool.complete' - }) - ) - - await waitFor(() => { - expect($previewTarget.get()?.source).toBe('./dist/index.html') - }) - - expect(window.localStorage.getItem('hermes.desktop.sessionPreviews.v1')).toContain('./dist/index.html') - }) - - it('registers html previews from edit inline diffs', async () => { + it('does not auto-open a preview from tool results', async () => { render( { @@ -160,9 +136,9 @@ describe('usePreviewRouting', () => { type: 'tool.complete' }) ) + act(() => handleEvent({ payload: { path: './dist/index.html' }, session_id: 'session-1', type: 'tool.complete' })) - await waitFor(() => { - expect($previewTarget.get()?.source).toBe('preview-demo.html') - }) + expect($previewTarget.get()).toBeNull() + expect(window.localStorage.getItem('hermes.desktop.sessionPreviews.v1')).toBeNull() }) }) diff --git a/apps/desktop/src/app/session/hooks/use-preview-routing.ts b/apps/desktop/src/app/session/hooks/use-preview-routing.ts index 0d48927af5e..d2c13ba56ab 100644 --- a/apps/desktop/src/app/session/hooks/use-preview-routing.ts +++ b/apps/desktop/src/app/session/hooks/use-preview-routing.ts @@ -10,8 +10,7 @@ import { getSessionPreviewRecord, progressPreviewServerRestart, requestPreviewReload, - setPreviewTarget, - setSessionPreviewTarget + setPreviewTarget } from '@/store/preview' import { $currentCwd } from '@/store/session' import type { RpcEvent } from '@/types/hermes' @@ -40,53 +39,6 @@ function activePreviewSessionId( return selectedStoredSessionId || routedSessionId || activeSessionIdRef.current || '' } -function looksLikePreviewTarget(value: string): boolean { - return /^https?:\/\//i.test(value) || /^file:\/\//i.test(value) || /^(?:\/|\.{1,2}\/|~\/).+/.test(value) -} - -function stripAnsi(value: string): string { - return value.replace(new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, 'g'), '') -} - -function htmlPathFromInlineDiff(value: string): string { - const cleaned = stripAnsi(value).replace(/^\s*┊\s*review diff\s*\n/i, '') - - for (const match of cleaned.matchAll(/(?:^|\s)(?:[ab]\/)?([^\s]+\.html?)(?=\s|$)/gi)) { - const candidate = match[1]?.trim() - - if (candidate) { - return candidate - } - } - - return '' -} - -function structuredPreviewCandidate(payload: unknown): string { - const record = asRecord(payload) - const fields = ['url', 'target', 'path', 'file', 'filepath', 'preview'] - - for (const field of fields) { - const value = record[field] - - if (typeof value === 'string') { - const target = value.trim() - - if (target && looksLikePreviewTarget(target)) { - return target - } - } - } - - const inlineDiff = record.inline_diff - - if (typeof inlineDiff === 'string') { - return htmlPathFromInlineDiff(inlineDiff) - } - - return '' -} - export function usePreviewRouting({ activeSessionIdRef, baseHandleGatewayEvent, @@ -99,6 +51,10 @@ export function usePreviewRouting({ const previewRegistry = useStore($sessionPreviewRegistry) const previewSessionId = activePreviewSessionId(activeSessionIdRef, routedSessionId, selectedStoredSessionId) + // Restore a *user-opened* preview when its session becomes active. Tool + // results no longer auto-register/open a preview — the inline preview card in + // the tool row is the only entry point, so HTML artifacts never pop the rail + // open on their own. useEffect(() => { if (currentView !== 'chat' || !previewSessionId) { setPreviewTarget(null) @@ -111,53 +67,6 @@ export function usePreviewRouting({ setPreviewTarget(record?.normalized ?? null) }, [currentView, previewRegistry, previewSessionId]) - const registerStructuredPreview = useCallback( - async (event: RpcEvent) => { - if ( - event.session_id && - event.session_id !== activeSessionIdRef.current && - event.session_id !== previewSessionId - ) { - return - } - - if (!event.type.startsWith('tool.')) { - return - } - - if (!previewSessionId) { - return - } - - const candidate = structuredPreviewCandidate(event.payload) - - if (!candidate) { - return - } - - const desktop = window.hermesDesktop - - if (!desktop?.normalizePreviewTarget) { - return - } - - const sessionId = previewSessionId - const cwd = currentCwd || '' - const target = await desktop.normalizePreviewTarget(candidate, cwd || undefined).catch(() => null) - - if ( - !target || - sessionId !== activePreviewSessionId(activeSessionIdRef, routedSessionId, selectedStoredSessionId) || - $currentCwd.get() !== cwd - ) { - return - } - - setSessionPreviewTarget(sessionId, target, 'tool-result', candidate) - }, - [activeSessionIdRef, currentCwd, previewSessionId, routedSessionId, selectedStoredSessionId] - ) - const restartPreviewServer = useCallback( async (url: string, context?: string) => { const sessionId = activeSessionIdRef.current @@ -210,13 +119,14 @@ export function usePreviewRouting({ return } - void registerStructuredPreview(event) - + // Only refresh an already-open live preview when a file changes; never + // open one unprompted. (Preview links are surfaced from the tool row into + // the status stack — see tool-fallback.tsx.) if ($previewTarget.get()?.kind === 'url' && gatewayEventCompletedFileDiff(event)) { requestPreviewReload() } }, - [activeSessionIdRef, baseHandleGatewayEvent, registerStructuredPreview] + [activeSessionIdRef, baseHandleGatewayEvent] ) return { handleDesktopGatewayEvent, restartPreviewServer } diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions.ts index f594d410c77..e737757ed91 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions.ts @@ -37,6 +37,7 @@ import { updateComposerAttachment } from '@/store/composer' import { resetSessionBackground } from '@/store/composer-status' +import { clearPreviewArtifacts } from '@/store/preview-status' import { clearNotifications, notify, notifyError } from '@/store/notifications' import { requestDesktopOnboarding } from '@/store/onboarding' import { $activeGatewayProfile, $newChatProfile, ensureGatewayProfile, normalizeProfileKey } from '@/store/profile' @@ -1643,6 +1644,7 @@ export function usePromptActions({ // rows (and kill the live processes) before the fresh run repopulates. clearSessionTodos(sessionId) resetSessionBackground(sessionId) + clearPreviewArtifacts(sessionId) clearNotifications() setMutableRef(busyRef, true) @@ -1705,6 +1707,7 @@ export function usePromptActions({ // processes) before the re-run repopulates them. clearSessionTodos(sessionId) resetSessionBackground(sessionId) + clearPreviewArtifacts(sessionId) clearNotifications() setMutableRef(busyRef, true) diff --git a/apps/desktop/src/components/assistant-ui/tool-fallback.tsx b/apps/desktop/src/components/assistant-ui/tool-fallback.tsx index 8d6a7eb157c..5e8a1a0b182 100644 --- a/apps/desktop/src/components/assistant-ui/tool-fallback.tsx +++ b/apps/desktop/src/components/assistant-ui/tool-fallback.tsx @@ -2,7 +2,7 @@ import { type ToolCallMessagePartProps, useAuiState } from '@assistant-ui/react' import { useStore } from '@nanostores/react' -import { createContext, type FC, type PropsWithChildren, type ReactNode, useContext, useMemo } from 'react' +import { createContext, type FC, type PropsWithChildren, type ReactNode, useContext, useEffect, useMemo } from 'react' import { AnsiText } from '@/components/assistant-ui/ansi-text' import { useElapsedSeconds } from '@/components/chat/activity-timer' @@ -10,7 +10,6 @@ import { ActivityTimerText } from '@/components/chat/activity-timer-text' import { CompactMarkdown } from '@/components/chat/compact-markdown' import { FileDiffPanel } from '@/components/chat/diff-lines' import { DisclosureRow } from '@/components/chat/disclosure-row' -import { PreviewAttachment } from '@/components/chat/preview-attachment' import { ZoomableImage } from '@/components/chat/zoomable-image' import { Button } from '@/components/ui/button' import { Codicon } from '@/components/ui/codicon' @@ -25,6 +24,8 @@ import { PrettyLink, LinkifiedText as SharedLinkifiedText, urlSlugTitleLabel } f import { AlertCircle, CheckCircle2 } from '@/lib/icons' import { useEnterAnimation } from '@/lib/use-enter-animation' import { cn } from '@/lib/utils' +import { recordPreviewArtifact } from '@/store/preview-status' +import { $activeSessionId, $currentCwd } from '@/store/session' import { $toolInlineDiffs } from '@/store/tool-diffs' import { $toolRowDismissed, dismissToolRow } from '@/store/tool-dismiss' import { $toolDisclosureOpen, $toolViewMode, setToolDisclosureOpen } from '@/store/tool-view' @@ -76,6 +77,8 @@ const TOOL_SECTION_LABEL_CLASS = 'mb-1 text-[0.65rem] font-medium uppercase trac const TOOL_SECTION_SURFACE_CLASS = 'max-h-20 max-w-full overflow-auto bg-transparent px-2 py-1.5 text-(--ui-text-secondary)' +const TOOL_EXPANDED_SHELL_CLASS = 'rounded-[0.3125rem] border border-(--ui-stroke-tertiary)' + const TOOL_SECTION_PRE_CLASS = cn(TOOL_SECTION_SURFACE_CLASS, 'font-mono text-[0.7rem] leading-relaxed') interface ToolStatusCopy { @@ -242,6 +245,22 @@ function ToolEntry({ part }: ToolEntryProps) { return buildToolView(p, inlineDiff) }, [inlineDiff, isPending, part]) + // Surface a previewable artifact (HTML file / localhost URL) as a compact link + // in the composer status stack rather than a bulky inline card. Uses the same + // detected target the old inline card did, keyed to the active session the + // stack reads from. Idempotent + dedup'd, so re-renders don't churn. + const activeSessionId = useStore($activeSessionId) + const currentCwd = useStore($currentCwd) + const previewTarget = view.previewTarget + + useEffect(() => { + if (isPending || !activeSessionId || !previewTarget || !isPreviewableTarget(previewTarget)) { + return + } + + recordPreviewArtifact(activeSessionId, previewTarget, currentCwd || '') + }, [activeSessionId, currentCwd, isPending, previewTarget]) + const detailSections = useMemo(() => { if (!view.detail) { return { body: '', summary: '' } @@ -291,12 +310,7 @@ function ToolEntry({ part }: ToolEntryProps) { Boolean(view.rawResult.trim()) const hasExpandableContent = Boolean( - (view.previewTarget && isPreviewableTarget(view.previewTarget)) || - view.imageUrl || - view.inlineDiff || - showDetail || - hasSearchHits || - toolViewMode === 'technical' + view.imageUrl || view.inlineDiff || showDetail || hasSearchHits || toolViewMode === 'technical' ) const copyAction = useMemo(() => toolCopyPayload(part, view), [part, view]) @@ -360,7 +374,7 @@ function ToolEntry({ part }: ToolEntryProps) {
)} - {!embedded && view.previewTarget && isPreviewableTarget(view.previewTarget) && ( - - )} {view.imageUrl && (
diff --git a/apps/desktop/src/components/chat/preview-attachment.tsx b/apps/desktop/src/components/chat/preview-attachment.tsx index b85d1b8b057..9cc90dff53e 100644 --- a/apps/desktop/src/components/chat/preview-attachment.tsx +++ b/apps/desktop/src/components/chat/preview-attachment.tsx @@ -104,16 +104,15 @@ export function PreviewAttachment({ source = 'manual', target }: { source?: Prev } return ( -
- +
+ -
-
{name}
-
{target}
-
+ + {name} +