diff --git a/apps/desktop/src/app/chat/close-tab.test.ts b/apps/desktop/src/app/chat/close-tab.test.ts index 95847fd925c..44368c80403 100644 --- a/apps/desktop/src/app/chat/close-tab.test.ts +++ b/apps/desktop/src/app/chat/close-tab.test.ts @@ -1,14 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { $rightRailActiveTabId, RIGHT_RAIL_PREVIEW_TAB_ID } from '@/store/layout' -import { - $filePreviewTabs, - $previewTarget, - clearSessionPreviewRegistry, - type PreviewTarget, - setCurrentSessionPreviewTarget -} from '@/store/preview' -import { $activeSessionId, $selectedStoredSessionId } from '@/store/session' +import { $rightRailActiveTabId } from '@/store/layout' +import { $previewTabs, closeRightRail, openPreview, type PreviewTarget } from '@/store/preview' import { closeActiveTab } from './close-tab' @@ -26,40 +19,34 @@ function fileTarget(path: string): PreviewTarget { describe('closeActiveTab', () => { beforeEach(() => { vi.stubGlobal('document', { activeElement: null }) - $activeSessionId.set('session-1') - $selectedStoredSessionId.set(null) + closeRightRail() window.localStorage.clear() - clearSessionPreviewRegistry() }) afterEach(() => { vi.unstubAllGlobals() - $activeSessionId.set(null) - $selectedStoredSessionId.set(null) - clearSessionPreviewRegistry() + closeRightRail() window.localStorage.clear() }) it('closes the active file preview tab (⌘W happy path)', () => { - setCurrentSessionPreviewTarget(fileTarget('/work/notes.md'), 'manual') + openPreview(fileTarget('/work/notes.md'), 'manual') - expect($filePreviewTabs.get()).toHaveLength(1) + expect($previewTabs.get()).toHaveLength(1) expect($rightRailActiveTabId.get()).toBe('file:file:///work/notes.md') expect(closeActiveTab()).toBe(true) - expect($filePreviewTabs.get()).toHaveLength(0) + expect($previewTabs.get()).toHaveLength(0) }) - it('closes the visible file tab when active selection is a ghost preview', () => { - // Active tab id stuck on live-preview after that target was cleared, while - // file tabs remain (UI falls back to tabs[0] until React syncs). ⌘W must - // close the visible file tab instead of no-op'ing via closeWorkspaceTab(). - setCurrentSessionPreviewTarget(fileTarget('/work/notes.md'), 'manual') - $previewTarget.set(null) - $rightRailActiveTabId.set(RIGHT_RAIL_PREVIEW_TAB_ID) + it('closes the visible tab when the active selection points at a tab that is gone', () => { + // The rail falls back to tabs[0] until React syncs the selection, so ⌘W has + // to act on what is actually on screen rather than no-op'ing. + openPreview(fileTarget('/work/notes.md'), 'manual') + $rightRailActiveTabId.set('file:file:///work/stale.md') - expect($filePreviewTabs.get()).toHaveLength(1) + expect($previewTabs.get()).toHaveLength(1) expect(closeActiveTab()).toBe(true) - expect($filePreviewTabs.get()).toHaveLength(0) + expect($previewTabs.get()).toHaveLength(0) }) }) diff --git a/apps/desktop/src/app/chat/close-tab.ts b/apps/desktop/src/app/chat/close-tab.ts index b5e15dd30cf..bf8b2899870 100644 --- a/apps/desktop/src/app/chat/close-tab.ts +++ b/apps/desktop/src/app/chat/close-tab.ts @@ -1,8 +1,7 @@ import { closeActiveTerminal } from '@/app/right-sidebar/terminal/terminals' import { closeWorkspaceTab } from '@/components/pane-shell/tree/store' import { isFocusWithin } from '@/lib/keybinds/combo' -import { $artifactTabs } from '@/store/artifacts' -import { $filePreviewTabs, $previewTarget, closeActiveRightRailTab } from '@/store/preview' +import { $previewTabs, closeActiveRightRailTab } from '@/store/preview' import { closeSessionTile, nextSessionTileForWorkspace } from '@/store/session-states' /** @@ -28,12 +27,10 @@ export function closeActiveTab(loadSessionIntoWorkspace?: (storedSessionId: stri return true } - // Prefer tab *presence* over the derived active file target. After the live - // preview is cleared, `$rightRailActiveTabId` can stay on `preview` while - // file tabs remain (the rail UI falls back to tabs[0]). Gating only on - // `$filePreviewTarget` made ⌘W fall through to closeWorkspaceTab() and look - // broken with a file tab still on screen. - if ($previewTarget.get() || $filePreviewTabs.get().length > 0 || $artifactTabs.get().length > 0) { + // Gate on tab *presence*, not on the selection: a stale `$rightRailActiveTabId` + // would otherwise make ⌘W fall through to closeWorkspaceTab() and look broken + // with a tab still on screen. The store resolves which tab that is. + if ($previewTabs.get().length > 0) { return closeActiveRightRailTab() } diff --git a/apps/desktop/src/app/chat/composer/attachments.tsx b/apps/desktop/src/app/chat/composer/attachments.tsx index 5b353436404..f50f74be668 100644 --- a/apps/desktop/src/app/chat/composer/attachments.tsx +++ b/apps/desktop/src/app/chat/composer/attachments.tsx @@ -1,5 +1,6 @@ import { useStore } from '@nanostores/react' +import { useSessionView } from '@/app/chat/session-view' import { Codicon } from '@/components/ui/codicon' import { Tip } from '@/components/ui/tooltip' import { useI18n } from '@/i18n' @@ -8,8 +9,7 @@ import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview' import { cn } from '@/lib/utils' import type { ComposerAttachment } from '@/store/composer' import { notifyError } from '@/store/notifications' -import { setCurrentSessionPreviewTarget } from '@/store/preview' -import { $currentCwd } from '@/store/session' +import { openPreview } from '@/store/preview' export function AttachmentList({ attachments, @@ -31,13 +31,15 @@ function AttachmentPill({ attachment, onRemove }: { attachment: ComposerAttachme const { t } = useI18n() const c = t.composer const Icon = { folder: FolderOpen, url: Link, image: ImageIcon, file: FileText, terminal: Terminal }[attachment.kind] - const cwd = useStore($currentCwd) + // The tile's cwd when this pill lives in a tile composer, not the primary's: + // a relative attachment path has to resolve against its own session's root. + const cwd = useStore(useSessionView().$cwd) const isUploading = attachment.uploadState === 'uploading' const hasUploadError = attachment.uploadState === 'error' const canPreview = attachment.kind !== 'folder' && attachment.kind !== 'terminal' && !isUploading const detail = attachment.detail && attachment.detail !== attachment.label ? attachment.detail : undefined - async function openPreview() { + async function openAttachmentPreview() { if (!canPreview) { return } @@ -70,7 +72,7 @@ function AttachmentPill({ attachment, onRemove }: { attachment: ComposerAttachme ? { ...preview, dataUrl: attachment.previewUrl, previewKind: 'image' as const } : preview - setCurrentSessionPreviewTarget(withBytes, 'manual', target) + openPreview(withBytes, 'manual') } catch (error) { notifyError(error, c.previewUnavailable) } @@ -89,7 +91,7 @@ function AttachmentPill({ attachment, onRemove }: { attachment: ComposerAttachme : 'border-border/60 hover:border-primary/35 hover:bg-accent/45' )} disabled={!canPreview} - onClick={() => void openPreview()} + onClick={() => void openAttachmentPreview()} type="button" > diff --git a/apps/desktop/src/app/chat/composer/hooks/use-auto-speak-replies.ts b/apps/desktop/src/app/chat/composer/hooks/use-auto-speak-replies.ts index 949b8f1020c..aa657ca8291 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-auto-speak-replies.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-auto-speak-replies.ts @@ -4,10 +4,11 @@ import { useEffect, useRef } from 'react' import { playSpeechText } from '@/lib/voice-playback' import { ownsAmbientCue } from '@/store/ambient' import { notifyError } from '@/store/notifications' -import { $messages } from '@/store/session' import { $voicePlayback } from '@/store/voice-playback' import { $autoSpeakReplies } from '@/store/voice-prefs' +import { useComposerScope } from '../scope' + interface AutoSpeakReply { id: string pending: boolean @@ -40,6 +41,9 @@ export function useAutoSpeakReplies({ sessionId }: UseAutoSpeakReplies) { const enabled = useStore($autoSpeakReplies) + // Wake on THIS composer's transcript: a tile subscribed to the primary's + // would never fire on its own replies (and would fire on someone else's). + const { $messages } = useComposerScope() const latest = useRef({ conversationActive, failureLabel, markSpoken, pendingReply }) latest.current = { conversationActive, failureLabel, markSpoken, pendingReply } @@ -83,5 +87,5 @@ export function useAutoSpeakReplies({ const stops = [$messages.subscribe(speakLatest), $voicePlayback.listen(speakLatest)] return () => stops.forEach(f => f()) - }, [enabled, sessionId]) + }, [$messages, enabled, sessionId]) } diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-voice.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-voice.ts index f8b75183f30..8e53096f322 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-composer-voice.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-voice.ts @@ -5,11 +5,11 @@ import { chatMessageText, collectUnspokenTurnSpeech } from '@/lib/chat-messages' import { triggerHaptic } from '@/lib/haptics' import { resetBrowseState } from '@/store/composer-input-history' import { notifyError } from '@/store/notifications' -import { $messages } from '@/store/session' import { $autoSpeakReplies, setAutoSpeakReplies } from '@/store/voice-prefs' import type { ComposerTarget } from '../focus' import { onComposerVoiceToggleRequest } from '../focus' +import { useComposerScope } from '../scope' import type { ChatBarProps } from '../types' import { useAutoSpeakReplies } from './use-auto-speak-replies' @@ -50,6 +50,8 @@ export function useComposerVoice({ target }: UseComposerVoiceArgs) { const { t } = useI18n() + // A tile's composer speaks ITS transcript, not the primary chat's. + const { $messages } = useComposerScope() const [voiceConversationActive, setVoiceConversationActive] = useState(false) const lastSpokenIdRef = useRef(null) diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index ac4f5dd3027..91029f83de7 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -1,6 +1,14 @@ import { ComposerPrimitive } from '@assistant-ui/react' import { useStore } from '@nanostores/react' -import { type ClipboardEvent, type FormEvent, type KeyboardEvent, useCallback, useEffect, useRef } from 'react' +import { + type ClipboardEvent, + type FormEvent, + type KeyboardEvent, + useCallback, + useEffect, + useMemo, + useRef +} from 'react' import { composerFill, composerSurfaceGlass } from '@/components/chat/composer-dock' import { Button } from '@/components/ui/button' @@ -11,7 +19,7 @@ import { sanitizeComposerInput } from '@/lib/composer-input-sanitize' import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images' import { triggerHaptic } from '@/lib/haptics' import { cn } from '@/lib/utils' -import { $compactionActive } from '@/store/compaction' +import { sessionCompacting } from '@/store/compaction' import { browseBackward, browseForward, deriveUserHistory, isBrowsingHistory } from '@/store/composer-input-history' import { POPOUT_WIDTH_REM } from '@/store/composer-popout' import { parkQueuedPrompts, removeQueuedPrompt, unparkQueuedPrompts } from '@/store/composer-queue' @@ -113,7 +121,7 @@ export function ChatBar({ // focus-bus key, and awaiting-input edge. Main scope = the legacy globals. const scope = useComposerScope() const attachments = useStore(scope.attachments.$attachments) - const compacting = useStore($compactionActive) + const compacting = useStore(useMemo(() => sessionCompacting(sessionId ?? null), [sessionId])) const scrolledUp = useStore($threadScrolledUp) const autoSpeak = useStore($autoSpeakReplies) // The turn is parked on the user (clarify / approval / sudo / secret). Esc must @@ -645,7 +653,7 @@ export function ChatBar({ // $messages is read imperatively (not subscribed) so the composer // doesn't re-render on every streaming delta flush. - const history = deriveUserHistory(scope.readMessages(), chatMessageText) + const history = deriveUserHistory(scope.$messages.get(), chatMessageText) const entry = browseBackward(sessionId, currentDraft, history) if (entry !== null) { @@ -670,7 +678,7 @@ export function ChatBar({ event.preventDefault() triggerKeyConsumedRef.current = true - const history = deriveUserHistory(scope.readMessages(), chatMessageText) + const history = deriveUserHistory(scope.$messages.get(), chatMessageText) const result = browseForward(sessionId, history) if (result !== null) { diff --git a/apps/desktop/src/app/chat/composer/scope.tsx b/apps/desktop/src/app/chat/composer/scope.tsx index 67581a39e30..53b43107507 100644 --- a/apps/desktop/src/app/chat/composer/scope.tsx +++ b/apps/desktop/src/app/chat/composer/scope.tsx @@ -25,18 +25,19 @@ export interface ComposerScope { attachments: ComposerAttachmentScope /** Only the main scope may pop out (the floating composer is a singleton). */ popoutAllowed: boolean - /** Imperative read of this scope's transcript (input-history browse) — - * never subscribed, so streaming stays out of the composer's renders. */ - readMessages: () => ChatMessage[] + /** This scope's transcript. Read it imperatively (input-history browse) to + * keep streaming out of the composer's renders; subscribe only off-render + * (auto-speak) where the reply edge is the whole point. */ + $messages: ReadableAtom /** Focus-bus routing key (`'main'` | `'tile:'`). */ target: ComposerTarget } export const MAIN_COMPOSER_SCOPE: ComposerScope = { $awaitingInput: $activeSessionAwaitingInput, + $messages, attachments: mainComposerScope, popoutAllowed: true, - readMessages: () => $messages.get(), target: 'main' } 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 index cf721d2ae93..dc40c31de2e 100644 --- a/apps/desktop/src/app/chat/composer/status-stack/preview-row.tsx +++ b/apps/desktop/src/app/chat/composer/status-stack/preview-row.tsx @@ -11,7 +11,7 @@ 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 { $previewTabSources, closePreviewForSource, openPreview } from '@/store/preview' import { type PreviewArtifact } from '@/store/preview-status' interface PreviewStatusRowProps { @@ -22,10 +22,10 @@ interface PreviewStatusRowProps { /** 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 openSources = useStore($previewTabSources) const previewPaneOpen = useStore($paneOpen(PREVIEW_PANE_ID)) const [opening, setOpening] = useState(false) - const isOpen = activePreview?.source === item.target && previewPaneOpen + const isOpen = openSources.includes(item.target) && previewPaneOpen const resolveTarget = async () => { const target = await normalizeOrLocalPreviewTarget(item.target, item.cwd || undefined) @@ -43,7 +43,7 @@ export const PreviewStatusRow = memo(function PreviewStatusRow({ item, onDismiss } if (isOpen) { - dismissPreviewTarget() + closePreviewForSource(item.target) return } @@ -51,7 +51,7 @@ export const PreviewStatusRow = memo(function PreviewStatusRow({ item, onDismiss setOpening(true) try { - setCurrentSessionPreviewTarget(await resolveTarget(), 'tool-result', item.target) + openPreview(await resolveTarget(), 'tool-result') } catch (error) { notifyError(error, t.preview.unavailable) } finally { diff --git a/apps/desktop/src/app/chat/right-rail/artifact-pane.tsx b/apps/desktop/src/app/chat/right-rail/artifact-pane.tsx deleted file mode 100644 index c463ee84c41..00000000000 --- a/apps/desktop/src/app/chat/right-rail/artifact-pane.tsx +++ /dev/null @@ -1,222 +0,0 @@ -import { useStore } from '@nanostores/react' -import { useEffect, useMemo, useState } from 'react' - -import { CopyButton } from '@/components/ui/copy-button' -import { Tip } from '@/components/ui/tooltip' -import { useI18n } from '@/i18n' -import { artifactDownloadName } from '@/lib/artifact-detect' -import { downloadTextFile } from '@/lib/download-text' -import { ChevronLeft, ChevronRight, Download, ExternalLink } from '@/lib/icons' -import { cn } from '@/lib/utils' -import { - $artifactRegistry, - $artifactVersionSelection, - type ArtifactRecord, - selectArtifactVersion -} from '@/store/artifacts' -import { notifyError } from '@/store/notifications' - -import { ArtifactLivePreview, ArtifactSourceView, composeArtifactHtml } from './artifact-renderers' -import { PreviewEmptyState } from './preview-file' - -type ArtifactViewMode = 'preview' | 'source' - -const MIME_BY_KIND = { code: 'text/plain', html: 'text/html', svg: 'image/svg+xml' } as const - -const HEADER_BUTTON_CLASS = - 'flex h-5 items-center gap-1 rounded-md px-1 text-[0.625rem] font-bold text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-40' - -/** Write the composed document to a real temp file through the existing - * buffer-save IPC, then hand it to the OS browser. A blob/data URL can't - * cross into the OS default browser, so a file on disk is the honest path. */ -async function openHtmlInBrowser(content: string): Promise { - const bridge = window.hermesDesktop - - if (!bridge?.saveImageBuffer || !bridge.openExternal) { - throw new Error('Desktop bridge unavailable') - } - - const bytes = new TextEncoder().encode(composeArtifactHtml(content)) - const path = await bridge.saveImageBuffer(bytes, '.html') - - if (!path) { - throw new Error('Could not write artifact file') - } - - const fileUrl = `file://${path.startsWith('/') ? '' : '/'}${path.replace(/\\/g, '/')}` - - if (bridge.openPreviewInBrowser) { - await bridge.openPreviewInBrowser(fileUrl) - - return - } - - await bridge.openExternal(fileUrl) -} - -function VersionStepper({ - current, - onSelect, - total -}: { - current: number - onSelect: (index: number) => void - total: number -}) { - const { t } = useI18n() - const copy = t.artifactPane - - if (total < 2) { - return null - } - - return ( -
- - - - {copy.versionOf(current + 1, total)} - - - -
- ) -} - -export function ArtifactPane({ artifactId }: { artifactId: string }) { - const { t } = useI18n() - const copy = t.artifactPane - const registry = useStore($artifactRegistry) - const versionSelection = useStore($artifactVersionSelection) - // View mode is per-pane, ephemeral: renderable artifacts open in preview. - const [userMode, setUserMode] = useState(null) - - // Reset the explicit mode when the pane is reused for another artifact. - useEffect(() => { - setUserMode(null) - }, [artifactId]) - - const record = useMemo(() => { - for (const records of Object.values(registry)) { - const found = records.find(candidate => candidate.id === artifactId) - - if (found) { - return found - } - } - - return null - }, [artifactId, registry]) - - if (!record) { - return - } - - const isRenderable = record.kind === 'html' || record.kind === 'svg' - const versionIndex = Math.min(versionSelection[artifactId] ?? record.versions.length - 1, record.versions.length - 1) - const version = record.versions[versionIndex]! - const isCurrentVersion = versionIndex >= record.versions.length - 1 - const mode: ArtifactViewMode = isRenderable ? (userMode ?? 'preview') : 'source' - const downloadName = artifactDownloadName(record.kind, record.language, record.title) - - const modeLabel: Record = { - preview: copy.modePreview, - source: copy.modeSource - } - - return ( -
-
-
- selectArtifactVersion(artifactId, index)} - total={record.versions.length} - /> - {!isCurrentVersion && ( - - )} -
- {isRenderable && - (['preview', 'source'] as const).map(candidate => ( - - ))} -
- - - - - {record.kind === 'html' && window.hermesDesktop && ( - - - - )} -
-
-
- {mode === 'preview' && isRenderable ? ( - - ) : ( - - )} -
-
- ) -} diff --git a/apps/desktop/src/app/chat/right-rail/artifact-renderers.tsx b/apps/desktop/src/app/chat/right-rail/artifact-renderers.tsx deleted file mode 100644 index 00ed9a58a58..00000000000 --- a/apps/desktop/src/app/chat/right-rail/artifact-renderers.tsx +++ /dev/null @@ -1,107 +0,0 @@ -import DOMPurify from 'dompurify' -import { useMemo } from 'react' -import ShikiHighlighter from 'react-shiki' - -import { chunkTextLines, useFixedRowWindow } from '@/components/chat/fixed-row-window' -import type { ArtifactKind } from '@/lib/artifact-detect' - -const SHIKI_THEME = { dark: 'github-dark-default', light: 'github-light-default' } as const -const SOURCE_CHUNK_LINES = 200 -const SOURCE_LINE_PX = 20 -const SOURCE_OVERSCAN_LINES = 400 - -/** Windowed, Shiki-highlighted source view for artifact content. Same fixed-row - * windowing as the file preview's SourceView so a 5k-line artifact scrolls - * smoothly, minus the gutter drag/selection machinery (artifact content has no - * on-disk path to reference lines against). */ -export function ArtifactSourceView({ language, text }: { language: string; text: string }) { - const chunks = useMemo(() => chunkTextLines(text, SOURCE_CHUNK_LINES), [text]) - const lastChunk = chunks.at(-1) - const totalLines = lastChunk ? lastChunk.start + lastChunk.lines.length : 0 - - const { afterRows, beforeRows, endChunk, onScroll, scrollerRef, startChunk } = useFixedRowWindow({ - overscanRows: SOURCE_OVERSCAN_LINES, - rowPx: SOURCE_LINE_PX, - rowsPerChunk: SOURCE_CHUNK_LINES, - totalRows: totalLines - }) - - const visibleChunks = chunks.slice(startChunk, endChunk + 1) - - return ( -
-
- {beforeRows > 0 &&
} - {visibleChunks.map(chunk => ( -
- - {chunk.text} - -
- ))} - {afterRows > 0 &&
} -
-
- ) -} - -/** Wrap an HTML fragment in a minimal document shell; full documents pass - * through untouched. Keeps generated fragments (no /) rendering - * with sane defaults instead of quirks-mode soup. */ -export function composeArtifactHtml(content: string): string { - if (/]|', - '', - '', - content, - '' - ].join('\n') -} - -/** - * Sandboxed live renderer for html/svg artifact content. - * - * HTML runs in an `