mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-23 10:42:00 +00:00
feat(desktop): detect tool previews into composer status stack
Register previewable artifacts from the tool row, feed a session-scoped store, and render compact rows above the composer. Remove the inline preview card.
This commit is contained in:
parent
cb17a9efb2
commit
d0af7fc954
6 changed files with 171 additions and 13 deletions
|
|
@ -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 })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
@ -242,6 +243,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 +308,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])
|
||||
|
|
@ -425,9 +437,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} />
|
||||
|
|
|
|||
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, [])
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue