diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index 1ecc76de8bc..4010f2f783e 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -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(null) diff --git a/apps/desktop/src/app/chat/composer/status-stack/index.tsx b/apps/desktop/src/app/chat/composer/status-stack/index.tsx index a13e039ecc6..b9cf2ffb99c 100644 --- a/apps/desktop/src/app/chat/composer/status-stack/index.tsx +++ b/apps/desktop/src/app/chat/composer/status-stack/index.tsx @@ -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: ( +
+ {previews.map(item => ( + dismissPreviewArtifact(sessionId, id)} /> + ))} +
+ ) + }) + } + if (queue) { sections.push({ key: 'queue', node: queue }) } diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions.ts index f594d410c77..e737757ed91 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions.ts @@ -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) diff --git a/apps/desktop/src/components/assistant-ui/tool-fallback.tsx b/apps/desktop/src/components/assistant-ui/tool-fallback.tsx index 8d6a7eb157c..fd7a9ad3cb6 100644 --- a/apps/desktop/src/components/assistant-ui/tool-fallback.tsx +++ b/apps/desktop/src/components/assistant-ui/tool-fallback.tsx @@ -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) && ( - - )} {view.imageUrl && (
diff --git a/apps/desktop/src/store/preview-status.test.ts b/apps/desktop/src/store/preview-status.test.ts new file mode 100644 index 00000000000..e9ffbf322a3 --- /dev/null +++ b/apps/desktop/src/store/preview-status.test.ts @@ -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() + }) +}) diff --git a/apps/desktop/src/store/preview-status.ts b/apps/desktop/src/store/preview-status.ts new file mode 100644 index 00000000000..618f06f7bdb --- /dev/null +++ b/apps/desktop/src/store/preview-status.ts @@ -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>({}) + +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, []) +}