diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs
index 50b3c7cf1..510405ac3 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 f033475c5..68f75c7b8 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/status-stack/preview-row.tsx b/apps/desktop/src/app/chat/composer/status-stack/preview-row.tsx
new file mode 100644
index 000000000..cc6893f0e
--- /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 c8cb9facc..ced02523d 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 = (
void
setPreviewShortcutActive?: (active: boolean) => void
openExternal: (url: string) => Promise
+ openPreviewInBrowser?: (url: string) => Promise
fetchLinkTitle: (url: string) => Promise
sanitizeWorkspaceCwd: (cwd?: null | string) => Promise<{ cwd: string; sanitized: boolean }>
settings: {
diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts
index f03f4c6e2..e1003f398 100644
--- a/apps/desktop/src/i18n/en.ts
+++ b/apps/desktop/src/i18n/en.ts
@@ -1671,6 +1671,7 @@ export const en: Translations = {
opening: 'Opening...',
hide: 'Hide',
openPreview: 'Open preview',
+ openInBrowser: 'Open in browser',
sourceLineTitle: 'Click to select · shift-click to extend · drag to composer',
source: 'SOURCE',
renderedPreview: 'PREVIEW',
diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts
index 33bc7c3dd..8b1c2231e 100644
--- a/apps/desktop/src/i18n/ja.ts
+++ b/apps/desktop/src/i18n/ja.ts
@@ -1800,6 +1800,7 @@ export const ja = defineLocale({
opening: '開いています...',
hide: '非表示',
openPreview: 'プレビューを開く',
+ openInBrowser: 'ブラウザで開く',
sourceLineTitle: 'クリックして選択 · Shift クリックで拡張 · コンポーザーにドラッグ',
source: 'ソース',
renderedPreview: 'プレビュー',
diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts
index fe27cd726..927a4fd4d 100644
--- a/apps/desktop/src/i18n/types.ts
+++ b/apps/desktop/src/i18n/types.ts
@@ -1308,6 +1308,7 @@ export interface Translations {
opening: string
hide: string
openPreview: string
+ openInBrowser: string
sourceLineTitle: string
source: string
renderedPreview: string
diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts
index adb835349..5864bd231 100644
--- a/apps/desktop/src/i18n/zh-hant.ts
+++ b/apps/desktop/src/i18n/zh-hant.ts
@@ -1743,6 +1743,7 @@ export const zhHant = defineLocale({
opening: '開啟中...',
hide: '隱藏',
openPreview: '開啟預覽',
+ openInBrowser: '在瀏覽器中開啟',
sourceLineTitle: '點擊選取 · shift 點擊擴展 · 拖曳至輸入框',
source: '原始碼',
renderedPreview: '預覽',
diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts
index 695f254e7..8976cb7c4 100644
--- a/apps/desktop/src/i18n/zh.ts
+++ b/apps/desktop/src/i18n/zh.ts
@@ -1848,6 +1848,7 @@ export const zh: Translations = {
opening: '正在打开...',
hide: '隐藏',
openPreview: '打开预览',
+ openInBrowser: '在浏览器中打开',
sourceLineTitle: '点击选择 · shift 点击扩展 · 拖到输入框',
source: '源码',
renderedPreview: '预览',
diff --git a/apps/desktop/src/store/layout.ts b/apps/desktop/src/store/layout.ts
index 77ce4635b..8caeb8b47 100644
--- a/apps/desktop/src/store/layout.ts
+++ b/apps/desktop/src/store/layout.ts
@@ -32,12 +32,14 @@ const PANES_FLIPPED_STORAGE_KEY = 'hermes.desktop.panesFlipped'
export const CHAT_SIDEBAR_PANE_ID = 'chat-sidebar'
export const FILE_BROWSER_PANE_ID = 'file-browser'
+export const PREVIEW_PANE_ID = 'preview'
export const RIGHT_RAIL_PREVIEW_TAB_ID = 'preview'
export type RightRailTabId = typeof RIGHT_RAIL_PREVIEW_TAB_ID | `file:${string}`
ensurePaneRegistered(CHAT_SIDEBAR_PANE_ID, { open: true })
ensurePaneRegistered(FILE_BROWSER_PANE_ID, { open: false })
+ensurePaneRegistered(PREVIEW_PANE_ID, { open: true })
export const $sidebarOpen: ReadableAtom = computed(
$paneStates,
diff --git a/apps/desktop/src/store/preview.test.ts b/apps/desktop/src/store/preview.test.ts
index 631cedc4d..d5d4807ef 100644
--- a/apps/desktop/src/store/preview.test.ts
+++ b/apps/desktop/src/store/preview.test.ts
@@ -1,6 +1,7 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
-import { $rightRailActiveTabId, RIGHT_RAIL_PREVIEW_TAB_ID } from './layout'
+import { $rightRailActiveTabId, PREVIEW_PANE_ID, RIGHT_RAIL_PREVIEW_TAB_ID } from './layout'
+import { $paneOpen } from './panes'
import {
$filePreviewTabs,
$filePreviewTarget,
@@ -69,12 +70,14 @@ describe('preview store', () => {
setCurrentSessionPreviewTarget(target, 'tool-result')
expect($previewTarget.get()).toEqual(withRenderMode(target, 'preview'))
+ expect($paneOpen(PREVIEW_PANE_ID).get()).toBe(true)
expect(getSessionPreviewRecord('session-1')?.normalized).toEqual(withRenderMode(target, 'preview'))
expect(window.localStorage.getItem('hermes.desktop.sessionPreviews.v1')).toContain('/work/demo.html')
dismissPreviewTarget()
expect($previewTarget.get()).toBeNull()
+ expect($paneOpen(PREVIEW_PANE_ID).get()).toBe(false)
expect(getSessionPreviewRecord('session-1')).toBeNull()
expect($sessionPreviewRegistry.get()['session-1']?.[0]?.dismissedAt).toEqual(expect.any(Number))
diff --git a/apps/desktop/src/store/preview.ts b/apps/desktop/src/store/preview.ts
index 65c2b887d..e3dda9c43 100644
--- a/apps/desktop/src/store/preview.ts
+++ b/apps/desktop/src/store/preview.ts
@@ -1,6 +1,13 @@
import { atom, computed } from 'nanostores'
-import { $rightRailActiveTabId, RIGHT_RAIL_PREVIEW_TAB_ID, type RightRailTabId, selectRightRailTab } from './layout'
+import {
+ $rightRailActiveTabId,
+ PREVIEW_PANE_ID,
+ RIGHT_RAIL_PREVIEW_TAB_ID,
+ type RightRailTabId,
+ selectRightRailTab
+} from './layout'
+import { setPaneOpen } from './panes'
import { $activeSessionId, $selectedStoredSessionId } from './session'
export interface PreviewTarget {
@@ -88,10 +95,15 @@ function isSamePreviewTarget(a: PreviewTarget | null, b: PreviewTarget | null):
)
}
+function showLivePreviewTab() {
+ setPaneOpen(PREVIEW_PANE_ID, true)
+ selectRightRailTab(RIGHT_RAIL_PREVIEW_TAB_ID)
+}
+
export function setPreviewTarget(target: PreviewTarget | null) {
if (isSamePreviewTarget($previewTarget.get(), target)) {
if (target) {
- selectRightRailTab(RIGHT_RAIL_PREVIEW_TAB_ID)
+ showLivePreviewTab()
}
return
@@ -100,7 +112,7 @@ export function setPreviewTarget(target: PreviewTarget | null) {
$previewTarget.set(target)
if (target) {
- selectRightRailTab(RIGHT_RAIL_PREVIEW_TAB_ID)
+ showLivePreviewTab()
}
}
@@ -115,6 +127,7 @@ function openFilePreviewTarget(target: PreviewTarget) {
const tab: FilePreviewTab = { id, target }
$filePreviewTabs.set(index === -1 ? [...current, tab] : current.map((item, i) => (i === index ? tab : item)))
+ setPaneOpen(PREVIEW_PANE_ID, true)
selectRightRailTab(id)
}
@@ -372,6 +385,8 @@ export function dismissPreviewTarget() {
if ($rightRailActiveTabId.get() === RIGHT_RAIL_PREVIEW_TAB_ID) {
selectRightRailTab($filePreviewTabs.get()[0]?.id ?? RIGHT_RAIL_PREVIEW_TAB_ID)
}
+
+ setPaneOpen(PREVIEW_PANE_ID, $filePreviewTabs.get().length > 0)
}
function closeFilePreviewTab(tabId: RightRailTabId) {
@@ -393,6 +408,10 @@ function closeFilePreviewTab(tabId: RightRailTabId) {
if ($rightRailActiveTabId.get() === tabId) {
selectRightRailTab(next[Math.min(index, next.length - 1)]?.id ?? RIGHT_RAIL_PREVIEW_TAB_ID)
}
+
+ if (next.length === 0 && !$previewTarget.get()) {
+ setPaneOpen(PREVIEW_PANE_ID, false)
+ }
}
export function closeRightRailTab(tabId: RightRailTabId) {
@@ -416,12 +435,14 @@ export function closeRightRail() {
}
$filePreviewTabs.set([])
+ setPaneOpen(PREVIEW_PANE_ID, false)
}
export function clearSessionPreviewRegistry() {
$sessionPreviewRegistry.set({})
setPreviewTarget(null)
$filePreviewTabs.set([])
+ setPaneOpen(PREVIEW_PANE_ID, false)
selectRightRailTab(RIGHT_RAIL_PREVIEW_TAB_ID)
}