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) }