From cb17a9efb2dffb35ab5f827f0766d17b94fab91f Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 22 Jun 2026 19:21:20 -0500 Subject: [PATCH 1/4] fix(desktop): stop auto-opening tool previews Drop gateway-event preview registration so HTML artifacts from tool results no longer pop the rail. De-dupe the inline preview card label. --- .../hooks/use-preview-routing.test.tsx | 32 +----- .../app/session/hooks/use-preview-routing.ts | 108 ++---------------- .../components/chat/preview-attachment.tsx | 13 +-- 3 files changed, 19 insertions(+), 134 deletions(-) diff --git a/apps/desktop/src/app/session/hooks/use-preview-routing.test.tsx b/apps/desktop/src/app/session/hooks/use-preview-routing.test.tsx index 1134ffe4fae..119bb51a040 100644 --- a/apps/desktop/src/app/session/hooks/use-preview-routing.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-preview-routing.test.tsx @@ -120,31 +120,7 @@ describe('usePreviewRouting', () => { 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/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} + + + + + + + } + 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 = ( 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 f03f4c6e2d7..e1003f39872 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 33bc7c3dd6e..8b1c2231e32 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 fe27cd7269a..927a4fd4db2 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 adb83534992..5864bd23113 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 695f254e78b..8976cb7c4ae 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 77ce4635b21..8caeb8b47ab 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 631cedc4d81..d5d4807ef53 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 65c2b887d50..e3dda9c4321 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) } From 7daa6d83fcaa4822f5a6f878c5e78f0d94ff1d26 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 22 Jun 2026 19:22:11 -0500 Subject: [PATCH 4/4] style(desktop): soften inline code and expanded tool chrome Drop the inline-code border; halve the expanded tool block radius. --- apps/desktop/src/components/assistant-ui/tool-fallback.tsx | 4 +++- apps/desktop/src/styles.css | 3 --- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/components/assistant-ui/tool-fallback.tsx b/apps/desktop/src/components/assistant-ui/tool-fallback.tsx index fd7a9ad3cb6..5e8a1a0b182 100644 --- a/apps/desktop/src/components/assistant-ui/tool-fallback.tsx +++ b/apps/desktop/src/components/assistant-ui/tool-fallback.tsx @@ -77,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 { @@ -372,7 +374,7 @@ function ToolEntry({ part }: ToolEntryProps) {
code { - border: 0.0625rem solid var(--ui-inline-code-border); background: var(--ui-inline-code-background); color: var(--ui-inline-code-foreground); }