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/composer-utils.test.ts b/apps/desktop/src/app/chat/composer/composer-utils.test.ts index 4df8463ba2d..062b2567738 100644 --- a/apps/desktop/src/app/chat/composer/composer-utils.test.ts +++ b/apps/desktop/src/app/chat/composer/composer-utils.test.ts @@ -2,12 +2,14 @@ import type { Unstable_TriggerItem } from '@assistant-ui/core' import { describe, expect, it } from 'vitest' import { + acceptsTriggerCompletion, isPendingDraftPersistCurrent, type PendingDraftPersist, pickPlaceholder, slashArgStage, slashChipKindForItem, - slashCommandToken + slashCommandToken, + type TriggerAcceptInput } from './composer-utils' const item = (group: string): Unstable_TriggerItem => @@ -39,6 +41,53 @@ describe('slashChipKindForItem', () => { }) }) +describe('acceptsTriggerCompletion', () => { + const press = (key: string, overrides: Partial = {}) => + acceptsTriggerCompletion({ + activeExplicit: false, + freeTextArgStage: false, + key, + kind: '/', + query: 'personality alic', + ...overrides + }) + + it('accepts on Enter / Tab / Space for a finite option list', () => { + expect(press('Enter')).toBe(true) + expect(press('Tab')).toBe(true) + expect(press(' ')).toBe(true) + }) + + it('ignores keys that are neither navigation nor acceptance', () => { + expect(press('a')).toBe(false) + expect(press('Escape')).toBe(false) + }) + + it('lets an `@` mention take a literal space', () => { + expect(press(' ', { kind: '@', query: 'src/comp' })).toBe(false) + expect(press('Enter', { kind: '@', query: 'src/comp' })).toBe(true) + }) + + it('types a space on a bare `/ ` instead of accepting', () => { + expect(press(' ', { query: '' })).toBe(false) + }) + + // The `/goal ` class: the popover may be live over free-form text, so + // the keys that mean something else in prose must keep meaning it. + it('sends the prose rather than the unchosen first row', () => { + expect(press('Enter', { freeTextArgStage: true, query: 'goal ship the redesign' })).toBe(false) + expect(press(' ', { freeTextArgStage: true, query: 'goal ship the' })).toBe(false) + }) + + it('accepts on Enter once the user has arrowed to a row deliberately', () => { + expect(press('Enter', { activeExplicit: true, freeTextArgStage: true, query: 'goal stat' })).toBe(true) + }) + + it('keeps Tab as the explicit accept even over free text', () => { + expect(press('Tab', { freeTextArgStage: true, query: 'goal stat' })).toBe(true) + }) +}) + describe('pickPlaceholder', () => { it('returns a member of the pool', () => { const pool = ['a', 'b', 'c'] as const diff --git a/apps/desktop/src/app/chat/composer/composer-utils.ts b/apps/desktop/src/app/chat/composer/composer-utils.ts index 547a210f06f..21e3c1ac4a1 100644 --- a/apps/desktop/src/app/chat/composer/composer-utils.ts +++ b/apps/desktop/src/app/chat/composer/composer-utils.ts @@ -4,6 +4,8 @@ import type { SlashChipKind } from '@/components/assistant-ui/directive-text' import type { ComposerAttachment } from '@/store/composer' import { setSessionPickerOpen } from '@/store/session' +import type { TriggerState } from './text-utils' + export const COMPOSER_STACK_BREAKPOINT_PX = 320 // Above the stack breakpoint but still cramped: the model pill sheds its label @@ -59,6 +61,48 @@ export const slashArgStage = (query: string) => query.includes(' ') /** The `/command` token of a slash query (`personality x` → `/personality`). */ export const slashCommandToken = (query: string) => `/${query.split(/\s+/, 1)[0]?.toLowerCase() ?? ''}` +export interface TriggerAcceptInput { + /** The user moved the highlight themselves (arrow keys) rather than + * inheriting the list's default first row. */ + activeExplicit: boolean + /** The trigger is a slash command whose argument is arbitrary prose. */ + freeTextArgStage: boolean + key: string + kind: TriggerState['kind'] + query: string +} + +/** + * Whether a keypress accepts the highlighted completion while the popover is + * open. Tab is always an accept — it has no other meaning in the composer. + * + * Enter and Space are conditional, because both mean something else while a + * free-text argument is being written (`/goal ship the redesign`). Space types + * a space, and Enter sends the message; letting either take the popover's + * pre-highlighted row would swap the prose the user is mid-sentence on for a + * subcommand they never chose. Enter still accepts once the user has arrowed + * to a row deliberately, so the highlight never lies about what Enter will do. + */ +export function acceptsTriggerCompletion({ + activeExplicit, + freeTextArgStage, + key, + kind, + query +}: TriggerAcceptInput): boolean { + if (key === 'Tab') { + return true + } + + if (key === 'Enter') { + return !freeTextArgStage || activeExplicit + } + + // Space is slash-only (an `@` mention takes a literal space) and gated to a + // non-empty query so a bare `/ ` still types a space. + return key === ' ' && kind === '/' && Boolean(query.trim()) && !freeTextArgStage +} + export interface QueueEditState { attachments: ComposerAttachment[] draft: string 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-trigger.test.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-trigger.test.ts index c7a4e2f138a..4239f781afa 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-composer-trigger.test.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-trigger.test.ts @@ -148,6 +148,44 @@ describe('useComposerTrigger — free-text slash arguments', () => { expect(editor.querySelector('[data-slash-kind]')).toBeNull() }) + it('treats the default highlight as a suggestion until the user arrows to a row', () => { + const editor = mountEditor('/goal stat') + const { hook } = mountTrigger(editor, [item('/goal status', 'Options')]) + + act(() => hook.result.current.refreshTrigger()) + expect(hook.result.current.triggerActiveExplicit).toBe(false) + + act(() => hook.result.current.moveTriggerActive(1)) + expect(hook.result.current.triggerActiveExplicit).toBe(true) + }) + + it('drops a deliberate selection once the query moves on', () => { + const editor = mountEditor('/goal stat') + const { hook } = mountTrigger(editor, [item('/goal status', 'Options')]) + + act(() => hook.result.current.refreshTrigger()) + act(() => hook.result.current.moveTriggerActive(1)) + + renderComposerContents(editor, '/goal start the migration') + act(() => hook.result.current.refreshTrigger()) + + expect(hook.result.current.triggerActiveExplicit).toBe(false) + }) + + it('keeps a multi-word /resume search typeable instead of firing the picker action', () => { + // The session list always ends in a "Browse all sessions…" action row, so + // an accept here doesn't insert a chip — it empties the composer and opens + // the overlay, taking the half-typed query with it. + const editor = mountEditor('/resume my new') + const { hook } = mountTrigger(editor, [item('/resume', 'Sessions')]) + + act(() => hook.result.current.refreshTrigger()) + + expect(hook.result.current.slashFreeTextArgStage).toBe(true) + expect(hook.result.current.commitTypedSlashDirective()).toBe(false) + expect(composerPlainText(editor)).toBe('/resume my new') + }) + it('still commits a fully typed finite option as one directive chip', () => { const editor = mountEditor('/personality creative') const { hook } = mountTrigger(editor, []) diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-trigger.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-trigger.ts index 5f0be41c80c..ea968fb3643 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-composer-trigger.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-trigger.ts @@ -53,6 +53,11 @@ export function useComposerTrigger({ }: UseComposerTriggerOptions) { const [trigger, setTrigger] = useState(null) const [triggerActive, setTriggerActive] = useState(0) + // The list highlights its first row on open, which is a suggestion rather + // than a choice. This records that the user moved the highlight themselves, + // which is what lets Enter accept a completion in a free-text argument stage + // without stealing prose from everyone who never touched the arrows. + const [triggerActiveExplicit, setTriggerActiveExplicit] = useState(false) const [triggerItems, setTriggerItems] = useState([]) // Set synchronously in keydown when the open trigger popover consumes a // navigation/control key (Arrow/Enter/Tab/Escape). The subsequent keyup must @@ -63,6 +68,11 @@ export function useComposerTrigger({ // re-rendered and the handler closure sees the post-keydown state. const triggerKeyConsumedRef = useRef(false) + const resetTriggerActive = useCallback(() => { + setTriggerActive(0) + setTriggerActiveExplicit(false) + }, []) + const refreshTrigger = useCallback(() => { const editor = editorRef.current @@ -80,7 +90,7 @@ export function useComposerTrigger({ if (!rawText.includes('@') && !rawText.includes('/')) { if (trigger) { setTrigger(null) - setTriggerActive(0) + resetTriggerActive() } return @@ -109,9 +119,9 @@ export function useComposerTrigger({ // caret move (mouseup) or a stray refresh — must preserve the user's // current selection instead of snapping back to the first item. if (detected?.kind !== trigger?.kind || detected?.query !== trigger?.query) { - setTriggerActive(0) + resetTriggerActive() } - }, [editorRef, trigger]) + }, [editorRef, resetTriggerActive, trigger]) const triggerAdapter: Unstable_TriggerAdapter | null = trigger?.kind === '@' ? at.adapter : trigger?.kind === '/' ? slash.adapter : null @@ -139,15 +149,23 @@ export function useComposerTrigger({ // Space/Tab — neither should dead-end on a popover. const argStageEmpty = trigger?.kind === '/' && slashArgStage(trigger.query) && !triggerLoading && !triggerItems.length - const slashFreeTextArgStage = - trigger?.kind === '/' && - slashArgStage(trigger.query) && - ['mixed', 'text'].includes(desktopSlashCommandArgumentMode(slashCommandToken(trigger.query)) ?? '') + const slashArgumentMode = + trigger?.kind === '/' && slashArgStage(trigger.query) + ? desktopSlashCommandArgumentMode(slashCommandToken(trigger.query)) + : null + + const slashFreeTextArgStage = slashArgumentMode === 'mixed' || slashArgumentMode === 'text' const closeTrigger = () => { setTrigger(null) setTriggerItems([]) - setTriggerActive(0) + resetTriggerActive() + } + + /** Step the highlight, marking it as the user's own deliberate pick. */ + const moveTriggerActive = (delta: number) => { + setTriggerActiveExplicit(true) + setTriggerActive(idx => (idx + delta + triggerItems.length) % triggerItems.length) } useEffect(() => { @@ -358,12 +376,14 @@ export function useComposerTrigger({ ascendTriggerPath, closeTrigger, commitTypedSlashDirective, + moveTriggerActive, refreshTrigger, replaceTriggerWithChip, setTriggerActive, slashFreeTextArgStage, trigger, triggerActive, + triggerActiveExplicit, triggerItems, triggerKeyConsumedRef, triggerLoading 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 6ce8289fa0a..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' @@ -22,7 +30,12 @@ import { $autoSpeakReplies } from '@/store/voice-prefs' import { useTheme } from '@/themes' import { AttachmentList } from './attachments' -import { COMPOSER_FADE_BACKGROUND, type QueueEditState, slashArgStage } from './composer-utils' +import { + acceptsTriggerCompletion, + COMPOSER_FADE_BACKGROUND, + type QueueEditState, + slashArgStage +} from './composer-utils' import { ContextMenu } from './context-menu' import { COMPOSER_AREAS, runComposerMiddleware } from './contrib' import { ComposerControls } from './controls' @@ -108,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 @@ -302,12 +315,14 @@ export function ChatBar({ ascendTriggerPath, closeTrigger, commitTypedSlashDirective, + moveTriggerActive, refreshTrigger, replaceTriggerWithChip, setTriggerActive, slashFreeTextArgStage, trigger, triggerActive, + triggerActiveExplicit, triggerItems, triggerKeyConsumedRef, triggerLoading @@ -528,7 +543,7 @@ export function ChatBar({ if (event.key === 'ArrowDown') { event.preventDefault() triggerKeyConsumedRef.current = true - setTriggerActive(idx => (idx + 1) % triggerItems.length) + moveTriggerActive(1) return } @@ -536,20 +551,21 @@ export function ChatBar({ if (event.key === 'ArrowUp') { event.preventDefault() triggerKeyConsumedRef.current = true - setTriggerActive(idx => (idx - 1 + triggerItems.length) % triggerItems.length) + moveTriggerActive(-1) return } - // Enter / Tab / Space all accept the highlighted item: a no-arg command - // commits its directive chip, an arg-taking command expands to its - // options step, and an arg option commits the full `/cmd arg` chip. Space - // is slash-only (an `@` mention takes a literal space) and gated to a - // non-empty query so a bare `/ ` still types a space. - const acceptOnSpace = - event.key === ' ' && trigger.kind === '/' && Boolean(trigger.query.trim()) && !slashFreeTextArgStage - - const accept = event.key === 'Enter' || event.key === 'Tab' || acceptOnSpace + // Accepting the highlighted item: a no-arg command commits its directive + // chip, an arg-taking command expands to its options step, and an arg + // option commits the full `/cmd arg` chip. + const accept = acceptsTriggerCompletion({ + activeExplicit: triggerActiveExplicit, + freeTextArgStage: slashFreeTextArgStage, + key: event.key, + kind: trigger.kind, + query: trigger.query + }) if (accept) { event.preventDefault() @@ -637,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) { @@ -662,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 `