mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-23 10:42:00 +00:00
Merge pull request #51103 from NousResearch/bb/desktop-tool-preview-cleanup
fix(desktop): manual tool previews via status stack
This commit is contained in:
commit
2a10b8384a
23 changed files with 390 additions and 156 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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<HTMLFormElement | null>(null)
|
||||
|
|
|
|||
|
|
@ -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: (
|
||||
<div className="px-1 py-0.5">
|
||||
{previews.map(item => (
|
||||
<PreviewStatusRow item={item} key={item.id} onDismiss={id => dismissPreviewArtifact(sessionId, id)} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
if (queue) {
|
||||
sections.push({ key: 'queue', node: queue })
|
||||
}
|
||||
|
|
|
|||
125
apps/desktop/src/app/chat/composer/status-stack/preview-row.tsx
Normal file
125
apps/desktop/src/app/chat/composer/status-stack/preview-row.tsx
Normal file
|
|
@ -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 (
|
||||
<StatusRow
|
||||
leading={<ChevronRight aria-hidden className="size-3 text-muted-foreground/80" />}
|
||||
onActivate={() => void togglePreview()}
|
||||
trailing={
|
||||
<span className="-my-1 flex items-center gap-0.5">
|
||||
<Tip label={t.preview.openInBrowser}>
|
||||
<Button
|
||||
aria-label={t.preview.openInBrowser}
|
||||
className="size-4 rounded-md text-muted-foreground/60 hover:text-foreground/90"
|
||||
onClick={event => {
|
||||
event.stopPropagation()
|
||||
void openInBrowser()
|
||||
}}
|
||||
size="icon-xs"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="link-external" size="0.75rem" />
|
||||
</Button>
|
||||
</Tip>
|
||||
<Tip label={t.statusStack.dismiss}>
|
||||
<Button
|
||||
aria-label={t.statusStack.dismiss}
|
||||
className="size-4 rounded-md text-muted-foreground/60 hover:text-foreground/90"
|
||||
onClick={event => {
|
||||
event.stopPropagation()
|
||||
onDismiss(item.id)
|
||||
}}
|
||||
size="icon-xs"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<X size={12} />
|
||||
</Button>
|
||||
</Tip>
|
||||
</span>
|
||||
}
|
||||
trailingVisible
|
||||
>
|
||||
<span className="min-w-0 max-w-[18rem] truncate text-[0.73rem] leading-4 text-foreground/92" title={item.target}>
|
||||
{item.label}
|
||||
</span>
|
||||
<span className={cn('shrink-0 text-[0.62rem] leading-4 text-muted-foreground/70', opening && 'animate-pulse')}>
|
||||
{opening ? t.preview.opening : isOpen ? t.preview.hide : t.preview.openPreview}
|
||||
</span>
|
||||
</StatusRow>
|
||||
)
|
||||
})
|
||||
|
|
@ -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 = (
|
||||
<Pane
|
||||
disabled={!chatOpen || (!previewTarget && !filePreviewTarget)}
|
||||
id="preview"
|
||||
id={PREVIEW_PANE_ID}
|
||||
key="preview"
|
||||
maxWidth={PREVIEW_RAIL_MAX_WIDTH}
|
||||
minWidth={PREVIEW_RAIL_MIN_WIDTH}
|
||||
|
|
|
|||
|
|
@ -120,31 +120,7 @@ describe('usePreviewRouting', () => {
|
|||
expect(window.hermesDesktop.normalizePreviewTarget).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('registers structured tool-result preview targets', async () => {
|
||||
render(
|
||||
<PreviewRoutingHarness
|
||||
onEvent={handler => {
|
||||
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(
|
||||
<PreviewRoutingHarness
|
||||
onEvent={handler => {
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) {
|
|||
<div
|
||||
className={cn(
|
||||
'min-w-0 max-w-full overflow-hidden text-[length:var(--conversation-tool-font-size)] text-(--ui-text-tertiary)',
|
||||
open && 'rounded-[0.625rem] border border-(--ui-stroke-tertiary)'
|
||||
open && TOOL_EXPANDED_SHELL_CLASS
|
||||
)}
|
||||
data-file-edit={isFileEdit && open ? '' : undefined}
|
||||
data-slot="tool-block"
|
||||
|
|
@ -425,9 +439,6 @@ function ToolEntry({ part }: ToolEntryProps) {
|
|||
text={copyAction.text}
|
||||
/>
|
||||
)}
|
||||
{!embedded && view.previewTarget && isPreviewableTarget(view.previewTarget) && (
|
||||
<PreviewAttachment source="tool-result" target={view.previewTarget} />
|
||||
)}
|
||||
{view.imageUrl && (
|
||||
<div className="max-w-72 overflow-hidden rounded-[0.25rem] border border-(--ui-stroke-tertiary)">
|
||||
<ZoomableImage alt={copy.outputAlt} className="h-auto w-full object-cover" src={view.imageUrl} />
|
||||
|
|
|
|||
|
|
@ -104,16 +104,15 @@ export function PreviewAttachment({ source = 'manual', target }: { source?: Prev
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="flex w-full max-w-160 flex-wrap items-center gap-2.5 rounded-lg border border-border/55 bg-card/55 px-2.5 py-1.5 text-sm">
|
||||
<span className="grid size-7 shrink-0 place-items-center rounded-md bg-muted/55 text-muted-foreground/85">
|
||||
<div className="flex w-full max-w-160 items-center gap-2 rounded-lg border border-border/55 bg-card/55 px-2.5 py-1.5 text-sm">
|
||||
<span className="grid size-6 shrink-0 place-items-center rounded-md bg-muted/55 text-muted-foreground/85">
|
||||
<MonitorPlay className="size-3.5" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-[0.78rem] font-medium leading-[1.15rem] text-foreground/90">{name}</div>
|
||||
<div className="truncate font-mono text-[0.66rem] leading-4 text-muted-foreground/70">{target}</div>
|
||||
</div>
|
||||
<span className="min-w-0 flex-1 truncate text-[0.78rem] font-medium text-foreground/90" title={target}>
|
||||
{name}
|
||||
</span>
|
||||
<button
|
||||
className="ml-auto shrink-0 rounded-md border border-border/55 bg-background/40 px-2 py-1 text-[0.7rem] font-medium text-muted-foreground transition-colors hover:bg-accent/55 hover:text-foreground disabled:opacity-50 max-[28rem]:ml-9 max-[28rem]:w-[calc(100%-2.25rem)]"
|
||||
className="shrink-0 rounded-md border border-border/55 bg-background/40 px-2 py-1 text-[0.7rem] font-medium text-muted-foreground transition-colors hover:bg-accent/55 hover:text-foreground disabled:opacity-50"
|
||||
disabled={opening}
|
||||
onClick={() => void togglePreview()}
|
||||
type="button"
|
||||
|
|
|
|||
1
apps/desktop/src/global.d.ts
vendored
1
apps/desktop/src/global.d.ts
vendored
|
|
@ -60,6 +60,7 @@ declare global {
|
|||
setTranslucency?: (payload: { intensity: number }) => void
|
||||
setPreviewShortcutActive?: (active: boolean) => void
|
||||
openExternal: (url: string) => Promise<void>
|
||||
openPreviewInBrowser?: (url: string) => Promise<void>
|
||||
fetchLinkTitle: (url: string) => Promise<string>
|
||||
sanitizeWorkspaceCwd: (cwd?: null | string) => Promise<{ cwd: string; sanitized: boolean }>
|
||||
settings: {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -1800,6 +1800,7 @@ export const ja = defineLocale({
|
|||
opening: '開いています...',
|
||||
hide: '非表示',
|
||||
openPreview: 'プレビューを開く',
|
||||
openInBrowser: 'ブラウザで開く',
|
||||
sourceLineTitle: 'クリックして選択 · Shift クリックで拡張 · コンポーザーにドラッグ',
|
||||
source: 'ソース',
|
||||
renderedPreview: 'プレビュー',
|
||||
|
|
|
|||
|
|
@ -1308,6 +1308,7 @@ export interface Translations {
|
|||
opening: string
|
||||
hide: string
|
||||
openPreview: string
|
||||
openInBrowser: string
|
||||
sourceLineTitle: string
|
||||
source: string
|
||||
renderedPreview: string
|
||||
|
|
|
|||
|
|
@ -1743,6 +1743,7 @@ export const zhHant = defineLocale({
|
|||
opening: '開啟中...',
|
||||
hide: '隱藏',
|
||||
openPreview: '開啟預覽',
|
||||
openInBrowser: '在瀏覽器中開啟',
|
||||
sourceLineTitle: '點擊選取 · shift 點擊擴展 · 拖曳至輸入框',
|
||||
source: '原始碼',
|
||||
renderedPreview: '預覽',
|
||||
|
|
|
|||
|
|
@ -1848,6 +1848,7 @@ export const zh: Translations = {
|
|||
opening: '正在打开...',
|
||||
hide: '隐藏',
|
||||
openPreview: '打开预览',
|
||||
openInBrowser: '在浏览器中打开',
|
||||
sourceLineTitle: '点击选择 · shift 点击扩展 · 拖到输入框',
|
||||
source: '源码',
|
||||
renderedPreview: '预览',
|
||||
|
|
|
|||
|
|
@ -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<boolean> = computed(
|
||||
$paneStates,
|
||||
|
|
|
|||
41
apps/desktop/src/store/preview-status.test.ts
Normal file
41
apps/desktop/src/store/preview-status.test.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
$previewStatusBySession,
|
||||
clearPreviewArtifacts,
|
||||
dismissPreviewArtifact,
|
||||
recordPreviewArtifact
|
||||
} from './preview-status'
|
||||
|
||||
beforeEach(() => $previewStatusBySession.set({}))
|
||||
|
||||
describe('recordPreviewArtifact', () => {
|
||||
it('appends new targets newest-last and is idempotent', () => {
|
||||
recordPreviewArtifact('s1', '/a/index.html', '/work')
|
||||
recordPreviewArtifact('s1', '/a/about.html', '/work')
|
||||
recordPreviewArtifact('s1', '/a/index.html', '/work')
|
||||
|
||||
expect($previewStatusBySession.get().s1.map(i => i.id)).toEqual(['/a/index.html', '/a/about.html'])
|
||||
})
|
||||
|
||||
it('caps the list and derives a label', () => {
|
||||
for (const n of [1, 2, 3, 4, 5]) {
|
||||
recordPreviewArtifact('s1', `/a/p${n}.html`, '/work')
|
||||
}
|
||||
|
||||
const list = $previewStatusBySession.get().s1
|
||||
expect(list).toHaveLength(4)
|
||||
expect(list[0].id).toBe('/a/p2.html')
|
||||
expect(list[3].label).toBe('p5.html')
|
||||
})
|
||||
|
||||
it('dismiss and clear remove rows', () => {
|
||||
recordPreviewArtifact('s1', '/a/index.html', '/work')
|
||||
recordPreviewArtifact('s1', '/a/about.html', '/work')
|
||||
dismissPreviewArtifact('s1', '/a/index.html')
|
||||
expect($previewStatusBySession.get().s1.map(i => i.id)).toEqual(['/a/about.html'])
|
||||
|
||||
clearPreviewArtifacts('s1')
|
||||
expect($previewStatusBySession.get().s1).toBeUndefined()
|
||||
})
|
||||
})
|
||||
79
apps/desktop/src/store/preview-status.ts
Normal file
79
apps/desktop/src/store/preview-status.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import { atom } from 'nanostores'
|
||||
|
||||
import { previewName } from '@/lib/preview-targets'
|
||||
|
||||
/**
|
||||
* Session-scoped feed of previewable artifacts (HTML files, localhost dev URLs)
|
||||
* a tool produced. Surfaced as compact links in the composer status stack —
|
||||
* NOT auto-opened and NOT a bulky inline card. Click opens the rail preview or
|
||||
* the browser; both are manual.
|
||||
*
|
||||
* Fed from the tool row itself (see tool-fallback.tsx) using the same detected
|
||||
* target the inline card used, so detection parity is exact.
|
||||
*/
|
||||
export interface PreviewArtifact {
|
||||
/** cwd captured at detection so a relative path still resolves on click. */
|
||||
cwd: string
|
||||
/** Dedupe key + display id (the raw target). */
|
||||
id: string
|
||||
label: string
|
||||
target: string
|
||||
}
|
||||
|
||||
const MAX_PER_SESSION = 4
|
||||
|
||||
export const $previewStatusBySession = atom<Record<string, PreviewArtifact[]>>({})
|
||||
|
||||
const writePreviews = (sid: string, items: PreviewArtifact[]) => {
|
||||
const current = $previewStatusBySession.get()
|
||||
|
||||
if (items.length === 0) {
|
||||
if (!current[sid]) {
|
||||
return
|
||||
}
|
||||
|
||||
const next = { ...current }
|
||||
delete next[sid]
|
||||
$previewStatusBySession.set(next)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
$previewStatusBySession.set({ ...current, [sid]: items })
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a detected artifact, newest last, capped. Idempotent: a target already
|
||||
* in the list keeps its slot (the tool row re-registers on every render, so this
|
||||
* must not churn the atom or reorder rows).
|
||||
*/
|
||||
export function recordPreviewArtifact(sid: string, target: string, cwd: string) {
|
||||
const raw = target.trim()
|
||||
|
||||
if (!sid || !raw) {
|
||||
return
|
||||
}
|
||||
|
||||
const list = $previewStatusBySession.get()[sid] ?? []
|
||||
|
||||
if (list.some(item => item.id === raw)) {
|
||||
return
|
||||
}
|
||||
|
||||
writePreviews(sid, [...list, { cwd, id: raw, label: previewName(raw), target: raw }].slice(-MAX_PER_SESSION))
|
||||
}
|
||||
|
||||
export function dismissPreviewArtifact(sid: string, id: string) {
|
||||
const list = $previewStatusBySession.get()[sid]
|
||||
|
||||
if (list) {
|
||||
writePreviews(
|
||||
sid,
|
||||
list.filter(item => item.id !== id)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export function clearPreviewArtifacts(sid: string) {
|
||||
writePreviews(sid, [])
|
||||
}
|
||||
|
|
@ -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))
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -264,7 +264,6 @@
|
|||
);
|
||||
--ui-chat-bubble-opaque-background: var(--ui-bg-editor);
|
||||
--ui-inline-code-background: color-mix(in srgb, #141414 5%, transparent);
|
||||
--ui-inline-code-border: color-mix(in srgb, #141414 8%, transparent);
|
||||
--ui-inline-code-foreground: color-mix(in srgb, #141414 88%, transparent);
|
||||
--ui-selection-background: color-mix(in srgb, #ffd24a 55%, transparent);
|
||||
|
||||
|
|
@ -408,7 +407,6 @@
|
|||
--backdrop-invert-mul: 0;
|
||||
|
||||
--ui-inline-code-background: color-mix(in srgb, #ffffff 7%, transparent);
|
||||
--ui-inline-code-border: color-mix(in srgb, #ffffff 10%, transparent);
|
||||
--ui-inline-code-foreground: color-mix(in srgb, #ffffff 88%, transparent);
|
||||
--ui-selection-background: color-mix(in srgb, #ffd24a 38%, transparent);
|
||||
}
|
||||
|
|
@ -1180,7 +1178,6 @@ canvas {
|
|||
}
|
||||
|
||||
[data-slot='aui_assistant-message-content'] .aui-md :not(pre) > code {
|
||||
border: 0.0625rem solid var(--ui-inline-code-border);
|
||||
background: var(--ui-inline-code-background);
|
||||
color: var(--ui-inline-code-foreground);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue