Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license

This commit is contained in:
yilsem committed 2026-09-05 13:26:46 +03:00
commit 03634b1ca3
11340 files changed
+3442369

No files matched your search

@@ -0,0 +1,42 @@
import type { CSSProperties } from 'react'
import { PanelEmpty } from '@/app/overlays/panel'
import { TITLEBAR_HEIGHT } from '@/app/shell/titlebar'
import { useI18n } from '@/i18n'
import { windowBrowserTabId } from '@/store/windows'
import { PreviewTilePane } from './right-rail/preview'
/**
* Dedicated shell for `?win=browser`: the in-app Browser, full-window, no
* session sidebar or layout tree. Same webview + address bar the docked tab
* uses — just in its own OS window.
*/
export function BrowserPopoutShell() {
const { t } = useI18n()
const tabId = windowBrowserTabId()
return (
<div
className="flex h-screen min-h-0 w-screen flex-col bg-(--ui-bg-chrome) text-(--ui-text-primary)"
data-contrib-shell=""
style={{ '--titlebar-height': `${TITLEBAR_HEIGHT}px` } as CSSProperties}
>
<div aria-hidden="true" className="relative shrink-0 bg-(--ui-bg-chrome)" style={{ height: TITLEBAR_HEIGHT }}>
{/* Same traffic-light / native-overlay carve-out as the main titlebar:
a full-bar drag region would eat the window buttons. */}
<div className="pointer-events-none absolute inset-y-0 left-0 w-(--titlebar-controls-left,14px) [-webkit-app-region:drag]" />
<div className="pointer-events-none absolute inset-y-0 left-[calc(var(--titlebar-controls-left,14px)+(var(--titlebar-control-size,24px)*2)+0.75rem)] right-[calc(var(--titlebar-tools-right,0.75rem)+0.75rem)] [-webkit-app-region:drag]" />
</div>
<div className="relative min-h-0 min-w-0 flex-1 overflow-hidden">
{tabId ? (
<PreviewTilePane tabId={tabId} />
) : (
<div className="grid h-full place-items-center">
<PanelEmpty description={t.preview.web.blankPageBody} icon="globe" />
</div>
)}
</div>
</div>
)
}
@@ -0,0 +1,48 @@
import { useRef } from 'react'
import type { DragKind } from '@/app/chat/hooks/use-file-drop-zone'
import { DROP_SHEET_BLUR_CLASS, DROP_SHEET_CLASS } from '@/components/ui/drop-affordance'
import { useI18n } from '@/i18n'
import { cn } from '@/lib/utils'
/**
* Full-bleed affordance shown while files or a session are dragged over the chat
* area. Always `pointer-events-none` so the drop lands on the real element
* underneath and the drop-zone handler claims it — the overlay is purely visual.
* The label names the outcome (attach files / link this chat); the last kind is
* held through the fade-out so it doesn't blank.
*/
export function ChatDropOverlay({ kind }: { kind: DragKind }) {
const { t } = useI18n()
const lastKind = useRef<DragKind>(kind)
if (kind) {
lastKind.current = kind
}
const shown = kind ?? lastKind.current
return (
<div
aria-hidden
className={cn(
'pointer-events-none absolute inset-0 z-40 flex items-center justify-center transition-opacity duration-150 ease-out',
kind ? 'opacity-100' : 'opacity-0'
)}
data-slot="chat-drop-overlay"
>
<div
className={cn(
DROP_SHEET_CLASS,
DROP_SHEET_BLUR_CLASS,
'absolute inset-2 border-[color-mix(in_srgb,var(--dt-composer-ring)_55%,transparent)] bg-[color-mix(in_srgb,var(--dt-card)_55%,transparent)]'
)}
/>
{shown && (
<span className="relative text-[11px] font-medium uppercase tracking-wide text-foreground">
{shown === 'session' ? t.composer.dropSession : t.composer.dropFiles}
</span>
)}
</div>
)
}
@@ -0,0 +1,54 @@
// The overlay used to run its own 80ms setInterval + setState braille ticker —
// the same mechanism class (per-tick DOM mutation scheduling a style recalc)
// that GlyphSpinner was rewritten to remove. It now renders GlyphSpinner, so
// what needs pinning is that no timer comes back, that the label still survives
// the fade-out, and that the spinner stops animating once the swap is done.
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ChatSwapOverlay } from './chat-swap-overlay'
afterEach(() => {
cleanup()
})
describe('ChatSwapOverlay', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.clearAllTimers()
vi.useRealTimers()
})
it('animates the glyph without any timer', () => {
const { container } = render(<ChatSwapOverlay profile="turqoise" />)
expect(container.querySelector('.glyph-spinner__strip')).toBeTruthy()
expect(vi.getTimerCount()).toBe(0)
vi.advanceTimersByTime(5_000)
expect(vi.getTimerCount()).toBe(0)
})
it('names the waking profile', () => {
render(<ChatSwapOverlay profile="turqoise" />)
expect(screen.getByText(/turqoise/)).toBeTruthy()
})
it('keeps the last profile name through the fade-out, with the glyph frozen', () => {
const { container, rerender } = render(<ChatSwapOverlay profile="turqoise" />)
expect(container.querySelector('.glyph-spinner')?.hasAttribute('data-paused')).toBe(false)
rerender(<ChatSwapOverlay profile={null} />)
// Label held so the overlay doesn't blank while it fades.
expect(screen.getByText(/turqoise/)).toBeTruthy()
// ...and the spinner stops, the way clearing the interval used to stop it.
expect(container.querySelector('.glyph-spinner')?.getAttribute('data-paused')).toBe('true')
})
})
@@ -0,0 +1,64 @@
import { useEffect, useState } from 'react'
import { GlyphSpinner } from '@/components/ui/glyph-spinner'
import { useI18n } from '@/i18n'
import { cn } from '@/lib/utils'
// Shown over the conversation while the live gateway swaps to another profile's
// backend (lazily spawned). Keeps the last profile name through the fade-out so
// the label doesn't blank. Purely visual — pointer-events-none.
export function ChatSwapOverlay({ profile }: { profile: string | null }) {
const { t } = useI18n()
const [label, setLabel] = useState<null | string>(profile)
useEffect(() => {
if (profile) {
setLabel(profile)
}
}, [profile])
return (
<div
aria-hidden
className={cn(
'pointer-events-none absolute inset-0 z-50 flex items-center justify-center transition-opacity duration-150 ease-out',
profile ? 'opacity-100' : 'opacity-0'
)}
>
<div className="flex items-center gap-2 bg-[color-mix(in_srgb,var(--dt-card)_92%,transparent)] px-4 py-2 font-mono text-[0.8125rem] text-foreground shadow-composer">
{/* Was a local 80ms setInterval + setState braille ticker — the same
mechanism class (per-tick DOM mutation scheduling style recalc)
that GlyphSpinner was rewritten to remove. `braille` is exactly the
frame set and 80ms cadence this used. `justify-start` keeps the
glyph left-aligned in its w-3 box the way the bare span was, and
`paused` restores the old "no ticking once the swap is done"
behaviour while the overlay fades out still mounted. */}
<GlyphSpinner className="w-3 justify-start text-(--ui-accent)" paused={!profile} spinner="braille" />
{t.composer.wakingProfile(label ?? '')}
</div>
</div>
)
}
// Subtle corner badge for a PAINT-FIRST wake (#89843): the stored transcript
// is already on screen and usable, but the active-profile gate hasn't caught
// up yet (shared-remote serves every profile through the primary socket).
// Deliberately quiet — a pill in the corner, not an overlay — because the
// content is real; only the background profile sync is still settling.
export function ChatSyncBadge({ profile }: { profile: string | null }) {
const { t } = useI18n()
if (!profile) {
return null
}
return (
<div
aria-live="polite"
className="pointer-events-none absolute right-3 top-2 z-30 flex items-center gap-1.5 rounded-full border border-border/50 bg-[color-mix(in_srgb,var(--dt-card)_92%,transparent)] px-2 py-0.5 font-mono text-[0.6875rem] text-muted-foreground shadow-composer"
>
<GlyphSpinner className="w-3 justify-start text-(--ui-accent)" spinner="braille" />
{t.desktop.hydrationSyncing(profile)}
</div>
)
}
+147
View File
@@ -0,0 +1,147 @@
import { atom } from 'nanostores'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const closeFocusedSessionTab = vi.fn(() => false)
const closeFocusedToolTab = vi.fn(() => false)
const nextSessionTileForWorkspace = vi.fn<() => null | string>(() => null)
const closeSessionTile = vi.fn()
const requestFreshSession = vi.fn()
vi.mock('@/components/pane-shell/tree/store', () => ({
closeFocusedSessionTab: () => closeFocusedSessionTab(),
closeFocusedToolTab: () => closeFocusedToolTab()
}))
vi.mock('@/store/session-states', () => ({
closeSessionTile: (...args: unknown[]) => closeSessionTile(...args),
nextSessionTileForWorkspace: () => nextSessionTileForWorkspace()
}))
vi.mock('@/store/profile', () => ({
// The layout store reads the sidebar's profile scope; this suite only cares
// about the fresh-session call.
$showAllProfiles: atom(false),
requestFreshSession: () => requestFreshSession(),
setShowAllProfiles: () => {}
}))
import { $previewTabs, closeRightRail, openPreview, type PreviewTarget } from '@/store/preview'
import { $activeSessionId, $selectedStoredSessionId } from '@/store/session'
import { $workspaceIsPage } from '../routes'
import { closeActiveTab, closeWorkspaceTab } from './close-tab'
function fileTarget(path: string): PreviewTarget {
return {
kind: 'file',
label: path,
path,
previewKind: 'text',
source: path,
url: `file://${path}`
}
}
/** Main is holding a loaded chat and nothing else is stacked with it. */
function loadedMainOnly() {
$selectedStoredSessionId.set('stored-a')
$activeSessionId.set('runtime-a')
}
beforeEach(() => {
vi.stubGlobal('document', { activeElement: null })
closeRightRail()
window.localStorage.clear()
$selectedStoredSessionId.set(null)
$activeSessionId.set(null)
$workspaceIsPage.set(false)
closeFocusedSessionTab.mockReturnValue(false)
closeFocusedToolTab.mockReturnValue(false)
nextSessionTileForWorkspace.mockReturnValue(null)
vi.clearAllMocks()
})
afterEach(() => {
vi.unstubAllGlobals()
closeRightRail()
window.localStorage.clear()
})
describe('closeActiveTab', () => {
// Preview tabs are layout-tree panes now, so ⌘W reaches them through the
// focused-zone rungs (closeFocusedSessionTab / closeFocusedToolTab → the
// pane's registered closer) rather than a rail-shaped special case. Open
// previews must therefore NOT claim the key on their own.
it('leaves ⌘W to the zone rungs even with previews open', () => {
openPreview(fileTarget('/work/notes.md'), 'manual')
closeFocusedToolTab.mockReturnValue(true)
expect($previewTabs.get()).toHaveLength(1)
expect(closeActiveTab()).toBe(true)
// The zone closed its own tab; the rail store was never consulted.
expect($previewTabs.get()).toHaveLength(1)
})
})
/**
* The main tab's own close. The workspace pane can never leave the tree, so
* every answer here is about what FILLS it — a stacked session, or an empty
* draft. The gesture used to dead-end whenever main was the only tab.
*/
describe('closeWorkspaceTab', () => {
it('shifts the next stacked session into main', () => {
loadedMainOnly()
nextSessionTileForWorkspace.mockReturnValue('stored-b')
const load = vi.fn()
expect(closeWorkspaceTab(load)).toBe(true)
expect(closeSessionTile).toHaveBeenCalledWith('stored-b')
expect(load).toHaveBeenCalledWith('stored-b')
// Promotion refills main — it must not ALSO blank it.
expect(requestFreshSession).not.toHaveBeenCalled()
})
it('drops a lone loaded main to a fresh draft', () => {
loadedMainOnly()
expect(closeWorkspaceTab(vi.fn())).toBe(true)
expect(requestFreshSession).toHaveBeenCalledTimes(1)
})
it('empties main even with no session loader wired', () => {
loadedMainOnly()
expect(closeWorkspaceTab()).toBe(true)
expect(requestFreshSession).toHaveBeenCalledTimes(1)
})
it('is a no-op on a blank draft — that IS the post-close state', () => {
expect(closeWorkspaceTab(vi.fn())).toBe(false)
expect(requestFreshSession).not.toHaveBeenCalled()
})
it('is a no-op over a full-page view, which owns no chat tab', () => {
loadedMainOnly()
$workspaceIsPage.set(true)
expect(closeWorkspaceTab(vi.fn())).toBe(false)
expect(requestFreshSession).not.toHaveBeenCalled()
})
it('⌘W reaches it once the terminal and zone tabs pass', () => {
loadedMainOnly()
expect(closeActiveTab(vi.fn())).toBe(true)
expect(requestFreshSession).toHaveBeenCalledTimes(1)
})
it('a focused tool panel (terminal / logs) claims ⌘W before main empties', () => {
loadedMainOnly()
closeFocusedToolTab.mockReturnValue(true)
expect(closeActiveTab(vi.fn())).toBe(true)
// The logs/terminal tab closed — main keeps its loaded chat.
expect(requestFreshSession).not.toHaveBeenCalled()
})
})
+89
View File
@@ -0,0 +1,89 @@
import { mainChatOccupied } from '@/app/open-session'
import { closeActiveTerminal } from '@/app/right-sidebar/terminal/terminals'
import { $workspaceIsPage } from '@/app/routes'
import { closeFocusedSessionTab, closeFocusedToolTab } from '@/components/pane-shell/tree/store'
import { isFocusWithin } from '@/lib/keybinds/combo'
import { requestFreshSession } from '@/store/profile'
import { $activeSessionId, $selectedStoredSessionId } from '@/store/session'
import { closeSessionTile, nextSessionTileForWorkspace } from '@/store/session-states'
/**
* Close the MAIN tab. The workspace pane itself can't leave the tree, so
* "closing" it means emptying it, and what fills the hole depends on what's
* stacked beside it:
*
* - session tabs stacked with it → the next one shifts INTO main (drop its
* tile, load it as the primary — the session stays alive, no busy prompt),
* - nothing stacked → main drops to a fresh "New session" draft.
*
* The second half is what makes the gesture honest when main is the ONLY tab:
* ⌘W / ⌘-click / middle-click used to be a dead key there, since the only
* available answer was "remove the pane", which this app never does.
*
* Returns false when there is nothing to close — a blank draft (already the
* post-close state) or a full-page view (skills / artifacts, which isn't a
* chat and owns no tab). ⌘W then stays a no-op; it never closes the window.
*
* `loadSessionIntoWorkspace` carries the app's route-based "load this session
* into main"; omitting it disables the promotion half.
*/
export function closeWorkspaceTab(loadSessionIntoWorkspace?: (storedSessionId: string) => void): boolean {
// Order matters — close the tile FIRST so the selection homes to the
// workspace instead of re-fronting the tile.
if (loadSessionIntoWorkspace) {
const next = nextSessionTileForWorkspace()
if (next) {
closeSessionTile(next)
loadSessionIntoWorkspace(next)
return true
}
}
if ($workspaceIsPage.get() || !mainChatOccupied($activeSessionId.get(), $selectedStoredSessionId.get())) {
return false
}
requestFreshSession()
return true
}
/**
* ⌘W — close the tab of the context you're in, by precedence:
* 1. a focused terminal → its active terminal tab,
* 2. the FOCUSED chat zone → its active tab (a session tile stacked into it).
* 3. a focused TOOL PANEL zone (terminal / logs) → its active tab.
* 4. the workspace tab itself — see `closeWorkspaceTab`.
* Returns false when nothing closes, so ⌘W is a no-op — it never closes the
* window. Shared by the keyboard path (Win/Linux) and the macOS
* menu-accelerator IPC.
*
* Preview tabs need no rung of their own: they are layout-tree panes like any
* other tab, so the focused-zone rungs close them through `closeTabPane`.
*
* Steps 2-4 follow the same focused zone ⌘1…⌘9 indexes, so a second chat zone
* with its own tab strip closes ITS tab instead of main's.
*/
export function closeActiveTab(loadSessionIntoWorkspace?: (storedSessionId: string) => void): boolean {
if (isFocusWithin('[data-terminal]')) {
closeActiveTerminal()
return true
}
// A closeable tab in the focused chat zone (a session tile that's the active
// tab) closes outright; the uncloseable workspace tab falls through.
if (closeFocusedSessionTab()) {
return true
}
// A tool panel zone hosts no chat strip, so the chat rung skips it — but its
// tabs close like any other. Without this ⌘W was dead over terminal / logs.
if (closeFocusedToolTab()) {
return true
}
return closeWorkspaceTab(loadSessionIntoWorkspace)
}
@@ -0,0 +1,180 @@
import type { Unstable_TriggerItem } from '@assistant-ui/core'
import { act, renderHook } from '@testing-library/react'
import { createRef } from 'react'
import { describe, expect, it, vi } from 'vitest'
import { useComposerTrigger } from './hooks/use-composer-trigger'
import { composerPlainText, renderComposerContents, RICH_INPUT_SLOT } from './rich-editor'
/**
* Folder navigation in the `@` popover, driven through the REAL hook against a
* real contentEditable.
*
* Tab and Enter used to be the same branch, so picking a folder always
* committed a chip and closed the menu — there was no way to walk into a
* subdirectory from the list. Tab now descends, Enter still commits, and
* Backspace climbs back out one segment.
*/
function folderItem(path: string): Unstable_TriggerItem {
return {
id: `@folder:${path}|0`,
type: 'folder',
label: path.split('/').filter(Boolean).pop() ?? path,
metadata: {
icon: 'folder',
display: path,
meta: 'dir',
rawText: `@folder:${path}`,
insertId: path
}
}
}
function setup(initialText: string) {
const editor = document.createElement('div')
editor.contentEditable = 'true'
// The real composer marks its editor with this slot; `composerPlainText`
// keys off it to decide whether a DIV contributes a trailing newline.
// Without it the harness would silently diverge from production text.
editor.dataset.slot = RICH_INPUT_SLOT
document.body.append(editor)
renderComposerContents(editor, initialText)
// Caret at the end, which is where a typed trigger always leaves it.
const range = document.createRange()
range.selectNodeContents(editor)
range.collapse(false)
const sel = window.getSelection()
sel?.removeAllRanges()
sel?.addRange(range)
const editorRef = createRef<HTMLDivElement>() as { current: HTMLDivElement | null }
editorRef.current = editor
const draftRef = { current: initialText }
const setComposerText = vi.fn()
const { result } = renderHook(() =>
useComposerTrigger({
at: { adapter: null, loading: false },
draftRef,
editorRef,
requestMainFocus: vi.fn(),
setComposerText,
slash: { adapter: null, loading: false }
})
)
act(() => {
result.current.refreshTrigger()
})
return { editor, result, setComposerText }
}
describe('@ folder navigation', () => {
it('Tab on a folder walks into it and keeps the popover open', () => {
const { editor, result } = setup('@app')
expect(result.current.trigger).toMatchObject({ kind: '@', query: 'app' })
act(() => {
result.current.replaceTriggerWithChip(folderItem('apps'), { descend: true })
})
// Plain text, not a chip — the token is still being typed.
expect(composerPlainText(editor)).toBe('@apps/')
expect(editor.querySelector('[data-ref-text]')).toBeNull()
})
it('descends repeatedly, one level per Tab', () => {
const { editor, result } = setup('@apps/desk')
act(() => {
result.current.replaceTriggerWithChip(folderItem('apps/desktop'), { descend: true })
})
expect(composerPlainText(editor)).toBe('@apps/desktop/')
})
it('Enter on a folder commits it as a chip instead of descending', () => {
const { editor, result } = setup('@app')
act(() => {
result.current.replaceTriggerWithChip(folderItem('apps'))
})
const chip = editor.querySelector('[data-ref-text]')
expect(chip).not.toBeNull()
expect(chip?.getAttribute('data-ref-kind')).toBe('folder')
expect(composerPlainText(editor)).toContain('@folder:')
})
it('only descends for `@` folders — a file pick still commits', () => {
const { editor, result } = setup('@main')
const file: Unstable_TriggerItem = {
id: '@file:src/main.tsx|0',
type: 'file',
label: 'main.tsx',
metadata: {
icon: 'file',
display: 'main.tsx',
meta: 'src',
rawText: '@file:src/main.tsx',
insertId: 'src/main.tsx'
}
}
act(() => {
result.current.replaceTriggerWithChip(file, { descend: true })
})
expect(editor.querySelector('[data-ref-kind="file"]')).not.toBeNull()
})
it('Backspace climbs out one segment at a time', () => {
const { editor, result } = setup('@apps/desktop/')
let handled = false
act(() => {
handled = result.current.ascendTriggerPath()
})
expect(handled).toBe(true)
expect(composerPlainText(editor)).toBe('@apps/')
})
it('Backspace drops a partially typed segment before its parent', () => {
const { editor, result } = setup('@apps/desk')
act(() => {
result.current.ascendTriggerPath()
})
expect(composerPlainText(editor)).toBe('@apps/')
})
it('leaves Backspace alone when there is no path to climb', () => {
const { result } = setup('@apps')
let handled = true
act(() => {
handled = result.current.ascendTriggerPath()
})
// No `/` in the query — normal character deletion must still happen.
expect(handled).toBe(false)
})
it('preserves text typed before the mention', () => {
const { editor, result } = setup('look at @app')
act(() => {
result.current.replaceTriggerWithChip(folderItem('apps'), { descend: true })
})
expect(composerPlainText(editor)).toBe('look at @apps/')
})
})
@@ -0,0 +1,262 @@
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { I18nProvider } from '@/i18n/context'
import type { ComposerAttachment } from '@/store/composer'
import { $previewTabs } from '@/store/preview'
import { AttachmentList } from './attachments'
const DATA_URL = 'data:image/png;base64,iVBORw0KGgoAAAANS'
const THUMBNAIL_URL = 'data:image/png;base64,dGh1bWJuYWls'
function makeAttachment(id: string, label = 'test.pdf'): ComposerAttachment {
return { id, kind: 'file', label }
}
async function renderWithI18n(ui: React.ReactNode) {
let result: ReturnType<typeof render>
await act(async () => {
result = render(
<I18nProvider configClient={{ getConfig: async () => ({}), saveConfig: async () => ({ ok: true }) }}>
{ui}
</I18nProvider>
)
})
return result!
}
describe('AttachmentList', () => {
afterEach(() => {
cleanup()
Reflect.deleteProperty(window, 'hermesDesktop')
vi.restoreAllMocks()
})
it('renders valid attachments', async () => {
const attachments = [makeAttachment('a', 'doc.pdf'), makeAttachment('b', 'img.png')]
await renderWithI18n(<AttachmentList attachments={attachments} />)
expect(screen.getByText('doc.pdf')).toBeDefined()
expect(screen.getByText('img.png')).toBeDefined()
})
it('renders empty list without error', async () => {
const { container } = await renderWithI18n(<AttachmentList attachments={[]} />)
const attachmentList = container.querySelector('[data-slot="composer-attachments"]')
expect(attachmentList).toBeDefined()
})
it('does not crash when attachments array contains undefined entries', async () => {
// Repro: session switch can leave stale/undefined entries in the
// attachments array, causing a TypeError at attachment.refText.
const attachments = [
makeAttachment('a', 'good.pdf'),
undefined as unknown as ComposerAttachment,
makeAttachment('b', 'also-good.png')
]
await expect(renderWithI18n(<AttachmentList attachments={attachments} />)).resolves.toBeTruthy()
// Only valid attachments should render
expect(screen.getByText('good.pdf')).toBeDefined()
expect(screen.getByText('also-good.png')).toBeDefined()
})
it('does not crash when attachments array contains null entries', async () => {
const attachments = [null as unknown as ComposerAttachment, makeAttachment('a', 'valid.txt')]
await expect(renderWithI18n(<AttachmentList attachments={attachments} />)).resolves.toBeTruthy()
expect(screen.getByText('valid.txt')).toBeDefined()
})
it('renders the thumbnail in the pill but opens the full-resolution image in the lightbox', async () => {
$previewTabs.set([])
const image: ComposerAttachment = {
id: 'img',
kind: 'image',
label: 'shot.png',
path: '/tmp/shot.png',
previewUrl: DATA_URL,
thumbnailUrl: THUMBNAIL_URL
}
await renderWithI18n(<AttachmentList attachments={[image]} />)
expect(screen.getByAltText<HTMLImageElement>('shot.png').getAttribute('src')).toBe(THUMBNAIL_URL)
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /shot\.png/ }))
})
// The lightbox renders the full-size image in a dialog; the rail stays empty.
const lightbox = await screen.findByRole('dialog')
expect(lightbox.querySelector<HTMLImageElement>('img')?.src).toBe(DATA_URL)
expect($previewTabs.get()).toHaveLength(0)
})
it('loads a path-backed full image only when opened and releases it when closed', async () => {
const readFileDataUrl = vi.fn(async () => DATA_URL)
Object.defineProperty(window, 'hermesDesktop', {
configurable: true,
value: { readFileDataUrl }
})
const image: ComposerAttachment = {
id: 'img-on-demand',
kind: 'image',
label: 'shot.png',
path: '/tmp/shot.png',
thumbnailUrl: THUMBNAIL_URL
}
const { container } = await renderWithI18n(<AttachmentList attachments={[image]} />)
expect(readFileDataUrl).not.toHaveBeenCalled()
expect(screen.getByAltText<HTMLImageElement>('shot.png').getAttribute('src')).toBe(THUMBNAIL_URL)
expect(container.querySelector(`img[src="${DATA_URL}"]`)).toBeNull()
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /shot\.png/ }))
})
expect(readFileDataUrl).toHaveBeenCalledOnce()
const lightboxImage = (await screen.findByRole('dialog')).querySelector<HTMLImageElement>('img')
expect(lightboxImage?.getAttribute('src')).toBe(DATA_URL)
await act(async () => {
fireEvent.click(lightboxImage!)
})
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull())
expect(container.querySelector(`img[src="${DATA_URL}"]`)).toBeNull()
})
it('falls back to the original host path after an image was staged for a different filesystem', async () => {
const stagedPath = '/root/.hermes/attachments/photo.png'
const hostPath = 'C:\\Users\\alice\\Pictures\\photo.png'
const readFileDataUrl = vi.fn(async (path: string) => {
if (path === hostPath) {
return DATA_URL
}
throw new Error(`not readable: ${path}`)
})
Object.defineProperty(window, 'hermesDesktop', {
configurable: true,
value: { readFileDataUrl }
})
const image: ComposerAttachment = {
attachedSessionId: 'session-1',
detail: hostPath,
id: 'image:photo.png',
kind: 'image',
label: 'photo.png',
path: stagedPath,
thumbnailUrl: THUMBNAIL_URL
}
await renderWithI18n(<AttachmentList attachments={[image]} />)
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /photo\.png/ }))
})
expect(readFileDataUrl).toHaveBeenCalledWith(stagedPath)
expect(readFileDataUrl).toHaveBeenCalledWith(hostPath)
expect((await screen.findByRole('dialog')).querySelector<HTMLImageElement>('img')?.src).toBe(DATA_URL)
})
it('does not let an old occurrence open a replacement lightbox after a deferred read', async () => {
let resolveOldRead!: (value: string) => void
const oldRead = new Promise<string>(resolve => {
resolveOldRead = resolve
})
const readFileDataUrl = vi.fn((path: string) =>
path === '/tmp/old.png' ? oldRead : Promise.resolve('data:image/png;base64,replacement')
)
Object.defineProperty(window, 'hermesDesktop', {
configurable: true,
value: { readFileDataUrl }
})
const oldOccurrence: ComposerAttachment = {
id: 'image:same-path',
kind: 'image',
label: 'same.png',
occurrenceId: 'occurrence-old',
path: '/tmp/old.png',
thumbnailUrl: THUMBNAIL_URL
}
const replacement: ComposerAttachment = {
...oldOccurrence,
occurrenceId: 'occurrence-replacement',
path: '/tmp/replacement.png'
}
const { rerender } = await renderWithI18n(<AttachmentList attachments={[oldOccurrence]} />)
fireEvent.click(screen.getByRole('button', { name: /same\.png/ }))
expect(readFileDataUrl).toHaveBeenCalledWith('/tmp/old.png')
rerender(
<I18nProvider configClient={{ getConfig: async () => ({}), saveConfig: async () => ({ ok: true }) }}>
<AttachmentList attachments={[replacement]} />
</I18nProvider>
)
await act(async () => {
resolveOldRead(DATA_URL)
await oldRead
})
expect(screen.queryByRole('dialog')).toBeNull()
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /same\.png/ }))
})
expect(readFileDataUrl).toHaveBeenCalledWith('/tmp/replacement.png')
expect((await screen.findByRole('dialog')).querySelector<HTMLImageElement>('img')?.src).toBe(
'data:image/png;base64,replacement'
)
})
it('removes an attachment from the composer chip', async () => {
const onRemove = vi.fn()
await renderWithI18n(<AttachmentList attachments={[makeAttachment('a', 'doc.pdf')]} onRemove={onRemove} />)
fireEvent.click(screen.getByRole('button', { name: 'Remove doc.pdf' }))
expect(onRemove).toHaveBeenCalledWith('a')
})
it('still routes a non-image attachment to the preview rail', async () => {
$previewTabs.set([])
const file: ComposerAttachment = { id: 'doc', kind: 'file', label: 'notes.md', path: '/tmp/notes.md' }
await renderWithI18n(<AttachmentList attachments={[file]} />)
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /notes\.md/ }))
})
expect(screen.queryByRole('dialog')).toBeNull()
expect($previewTabs.get().map(tab => tab.target.path)).toEqual(['/tmp/notes.md'])
})
})
@@ -0,0 +1,235 @@
import { useStore } from '@nanostores/react'
import { useState } from 'react'
import { useSessionView } from '@/app/chat/session-view'
import { ImageLightbox } from '@/components/chat/zoomable-image'
import { Codicon } from '@/components/ui/codicon'
import { Tip } from '@/components/ui/tooltip'
import { useImageDownload } from '@/hooks/use-image-download'
import { useI18n } from '@/i18n'
import { readDesktopFileDataUrlLocalFirst } from '@/lib/desktop-fs'
import { AlertCircle, FileText, FolderOpen, ImageIcon, Link, Loader2, MessageCode, Terminal } from '@/lib/icons'
import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
import { cn } from '@/lib/utils'
import type { ComposerAttachment } from '@/store/composer'
import { notifyError } from '@/store/notifications'
import { openPreview } from '@/store/preview'
export function AttachmentList({
attachments,
onRemove
}: {
attachments: ComposerAttachment[]
onRemove?: (id: string) => void
}) {
return (
<div className="flex max-w-full flex-wrap gap-1.5 px-1 pt-1" data-slot="composer-attachments">
{attachments.filter(Boolean).map(attachment => (
<AttachmentPill
attachment={attachment}
key={attachment.occurrenceId ? `occ:${attachment.occurrenceId}` : attachment.id}
onRemove={onRemove}
/>
))}
</div>
)
}
function AttachmentPill({ attachment, onRemove }: { attachment: ComposerAttachment; onRemove?: (id: string) => void }) {
const { t } = useI18n()
const c = t.composer
const Icon = {
file: FileText,
folder: FolderOpen,
image: ImageIcon,
review: MessageCode,
terminal: Terminal,
url: Link
}[attachment.kind]
// 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'
// A review card's detail is its resolved-comment JSON, not a previewable
// path — clicking it should do nothing rather than toast a bogus failure.
const canPreview =
attachment.kind !== 'folder' && attachment.kind !== 'terminal' && attachment.kind !== 'review' && !isUploading
const detail =
attachment.kind !== 'review' && attachment.detail && attachment.detail !== attachment.label
? attachment.detail
: undefined
// Keep full image bytes out of composer state. New chips read their path only
// when clicked; previewUrl remains a compatibility fallback for older drafts.
const [loadedImageSrc, setLoadedImageSrc] = useState<string>()
const lightboxSrc = attachment.kind === 'image' && !isUploading ? attachment.previewUrl || loadedImageSrc : undefined
const [lightboxOpen, setLightboxOpen] = useState(false)
const { download, saving } = useImageDownload(lightboxSrc)
async function openAttachment() {
if (!canPreview) {
return
}
if (attachment.kind === 'image') {
try {
let source = lightboxSrc || ''
// Upload may replace `path` with a gateway-side staged path while
// `detail` still carries the original host path. If submit then fails,
// keep the surviving chip previewable across split-filesystem setups.
if (!source) {
const paths = [attachment.path, attachment.detail].filter(
(path, index, candidates): path is string => Boolean(path) && candidates.indexOf(path) === index
)
let lastError: unknown
for (const path of paths) {
try {
source = await readDesktopFileDataUrlLocalFirst(path)
if (source) {
break
}
} catch (error) {
lastError = error
}
}
if (!source && lastError) {
throw lastError
}
}
if (!source) {
throw new Error(c.couldNotPreview(attachment.label))
}
setLoadedImageSrc(source)
setLightboxOpen(true)
} catch (error) {
notifyError(error, c.previewUnavailable)
}
return
}
const rawTarget =
attachment.path ||
attachment.detail ||
attachment.refText?.replace(/^@(file|image|url):/, '') ||
attachment.label ||
''
const target = rawTarget.replace(/^`|`$/g, '')
if (!target) {
return
}
try {
const preview = await normalizeOrLocalPreviewTarget(target, cwd || undefined)
if (!preview) {
throw new Error(c.couldNotPreview(attachment.label))
}
openPreview(preview, 'manual')
} catch (error) {
notifyError(error, c.previewUnavailable)
}
}
return (
<>
<Tip label={attachment.path || attachment.detail || attachment.label}>
<div className="group/attachment relative min-w-0 shrink-0">
<button
aria-busy={isUploading || undefined}
aria-label={canPreview ? c.previewLabel(attachment.label) : attachment.label}
className={cn(
'flex max-w-56 items-center gap-2 rounded-2xl border bg-background/50 px-2 py-1.5 text-left shadow-[inset_0_1px_0_rgba(255,255,255,0.18)] transition-colors disabled:cursor-default',
hasUploadError
? 'border-destructive/45 hover:border-destructive/60'
: 'border-border/60 hover:border-primary/35 hover:bg-accent/45'
)}
disabled={!canPreview}
onClick={() => void openAttachment()}
type="button"
>
<span className="relative grid size-8 shrink-0 place-items-center overflow-hidden rounded-lg border border-border/55 bg-muted/35 text-muted-foreground">
{(attachment.thumbnailUrl || attachment.previewUrl) && attachment.kind === 'image' ? (
<img
alt={attachment.label}
className="size-full object-cover"
draggable={false}
src={attachment.thumbnailUrl ?? attachment.previewUrl}
/>
) : (
<Icon className="size-3.5" />
)}
{isUploading && (
<span className="absolute inset-0 grid place-items-center bg-background/60 backdrop-blur-[1px]">
<Loader2 className="size-3.5 animate-spin text-foreground/75" />
</span>
)}
{hasUploadError && (
<span className="absolute inset-0 grid place-items-center bg-destructive/15">
<AlertCircle className="size-3.5 text-destructive" />
</span>
)}
</span>
<span className="min-w-0">
<span className="block truncate text-[0.72rem] font-medium leading-4 text-foreground/90">
{attachment.label}
</span>
{detail && (
<span
className={cn(
'block truncate text-[0.62rem] leading-3.5',
hasUploadError ? 'text-destructive/80' : 'text-muted-foreground/65'
)}
>
{detail}
</span>
)}
</span>
</button>
{onRemove && (
<button
aria-label={c.removeAttachment(attachment.label)}
className="absolute -right-1 -top-1 grid size-3.5 place-items-center rounded-full border border-border/70 bg-background text-muted-foreground opacity-0 shadow-xs transition hover:bg-accent hover:text-foreground group-hover/attachment:opacity-100 focus-visible:opacity-100"
onClick={() => onRemove(attachment.id)}
type="button"
>
<Codicon name="close" size="0.625rem" />
</button>
)}
</div>
</Tip>
{lightboxSrc && (
<ImageLightbox
alt={attachment.label}
copy={t.desktop}
onClick={download}
onOpenChange={open => {
setLightboxOpen(open)
if (!open && !attachment.previewUrl) {
setLoadedImageSrc(undefined)
}
}}
open={lightboxOpen}
saving={saving}
src={lightboxSrc}
/>
)}
</>
)
}
@@ -0,0 +1,51 @@
import type { Unstable_TriggerAdapter } from '@assistant-ui/core'
import { ComposerPrimitive } from '@assistant-ui/react'
import type { ReactNode } from 'react'
import { composerPanelCard } from '@/components/chat/composer-dock'
import { cn } from '@/lib/utils'
// A standalone glassy panel floating just off the composer edge, inset from the
// left. Skin is the shared composerPanelCard (also used by the attach menu).
const DRAWER_SHELL = cn(
'absolute left-2 z-50 w-80 max-w-[calc(100%-1rem)] max-h-[min(22rem,calc(100vh-8rem))]',
'overflow-y-auto overscroll-contain p-1 text-popover-foreground',
composerPanelCard
)
export const COMPLETION_DRAWER_CLASS = cn(DRAWER_SHELL, 'bottom-full mb-1')
export const COMPLETION_DRAWER_BELOW_CLASS = cn(DRAWER_SHELL, 'top-full mt-1')
export function ComposerCompletionDrawer({
adapter,
ariaLabel,
char,
children
}: {
adapter: Unstable_TriggerAdapter
ariaLabel: string
char: string
children: ReactNode
}) {
return (
<ComposerPrimitive.Unstable_TriggerPopover
adapter={adapter}
aria-label={ariaLabel}
char={char}
className={COMPLETION_DRAWER_CLASS}
data-slot="composer-completion-drawer"
>
{children}
</ComposerPrimitive.Unstable_TriggerPopover>
)
}
export function CompletionDrawerEmpty({ children, title }: { children?: ReactNode; title: string }) {
return (
<div className="px-3 py-3 text-xs text-(--ui-text-tertiary)">
<p>{title}</p>
{children && <p className="mt-1 text-xs text-(--ui-text-tertiary)">{children}</p>}
</div>
)
}
@@ -0,0 +1,106 @@
// @vitest-environment jsdom
import { act, cleanup, render } from '@testing-library/react'
import { useCallback, useRef } from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest'
afterEach(cleanup)
// Regression repro for #49903: on desktop v0.17.0 the composer threw an
// uncaught `Error: Composer is not available` at startup and the input went
// unresponsive. The throw comes from @assistant-ui/core's composer-runtime —
// every *mutator* (setText/send/…) does `if (!core) throw new Error("Composer
// is not available")` when the thread's composer core isn't bound yet. Unlike
// the read path (`s.composer.text`, which is null-safe: `runtime?.text ?? ""`),
// the mutators have no graceful fallback. ChatBar's mount-time effects (draft
// restore, clearDraft, external inserts) push text via `aui.composer().setText`
// before the core binds, and the popout refactor (#49488) widened that window,
// so the throw surfaced as an uncaught error that wedged the input.
//
// The fix wraps every `aui.composer().setText` call in a `setComposerText`
// helper that swallows the unbound-core throw — the contentEditable DOM +
// draftRef already hold the text and the draft⇄editor sync re-applies it once
// the core attaches, so nothing is lost. This Harness mirrors that helper
// faithfully (same try/catch shape) over a fake `aui` whose composer can be
// toggled bound/unbound, the way the assistant-ui runtime behaves across mount.
interface FakeComposer {
setText: (value: string) => void
}
// Mirror of index.tsx's `useAui()` composer surface: composer() returns a
// runtime whose setText throws exactly like @assistant-ui/core when unbound.
function makeFakeAui(bound: { current: boolean }, applied: string[]) {
const composer: FakeComposer = {
setText(value: string) {
if (!bound.current) {
throw new Error('Composer is not available')
}
applied.push(value)
}
}
return { composer: () => composer }
}
function Harness({
bound,
applied,
onError
}: {
applied: string[]
bound: { current: boolean }
onError: (err: unknown) => void
}) {
const aui = useRef(makeFakeAui(bound, applied)).current
// Verbatim mirror of the production `setComposerText` helper in index.tsx.
const setComposerText = useCallback(
(value: string) => {
try {
aui.composer().setText(value)
} catch {
// Composer core not bound yet — swallow so the input stays usable.
}
},
[aui]
)
// A draft-restore-on-mount that fires while the core may still be unbound,
// exactly like loadIntoComposer/clearDraft do on startup.
try {
setComposerText('restored draft')
} catch (err) {
onError(err)
}
return null
}
describe('setComposerText guard (#49903)', () => {
it('swallows the unbound-core throw at startup instead of crashing the renderer', () => {
const applied: string[] = []
const bound = { current: false }
const onError = vi.fn()
expect(() => render(<Harness applied={applied} bound={bound} onError={onError} />)).not.toThrow()
// The guard absorbed the throw — nothing escaped to the renderer, and no
// assistant-ui write landed (core was unbound).
expect(onError).not.toHaveBeenCalled()
expect(applied).toEqual([])
})
it('writes through to the composer once the core is bound', () => {
const applied: string[] = []
const bound = { current: true }
const onError = vi.fn()
act(() => {
render(<Harness applied={applied} bound={bound} onError={onError} />)
})
expect(onError).not.toHaveBeenCalled()
expect(applied).toEqual(['restored draft'])
})
})
@@ -0,0 +1,179 @@
import type { Unstable_TriggerItem } from '@assistant-ui/core'
import { describe, expect, it } from 'vitest'
import {
acceptsTriggerCompletion,
implicitSlashAcceptIndex,
isPendingDraftPersistCurrent,
type PendingDraftPersist,
pickPlaceholder,
shouldDisableComposerInput,
slashArgStage,
slashChipKindForItem,
slashCommandToken,
type TriggerAcceptInput
} from './composer-utils'
const item = (group: string): Unstable_TriggerItem =>
({ id: 'x', type: 'slash', label: 'x', metadata: { group } }) as unknown as Unstable_TriggerItem
describe('shouldDisableComposerInput', () => {
it.each(['idle', 'connecting', 'closed', 'error'] as const)(
'keeps the draft editable while the gateway is %s',
gatewayState => {
expect(shouldDisableComposerInput(true, gatewayState)).toBe(false)
}
)
it('fails closed when connection atoms disagree about an open gateway', () => {
expect(shouldDisableComposerInput(true, 'open')).toBe(true)
})
it.each(['idle', 'connecting', 'open', 'closed', 'error'] as const)(
'never disables an otherwise enabled composer while the gateway is %s',
gatewayState => {
expect(shouldDisableComposerInput(false, gatewayState)).toBe(false)
}
)
})
describe('slashArgStage', () => {
it('is true only once the query is past the command name', () => {
expect(slashArgStage('personality')).toBe(false)
expect(slashArgStage('personality alice')).toBe(true)
})
})
describe('slashCommandToken', () => {
it('extracts the lowercased /command token', () => {
expect(slashCommandToken('Personality alice')).toBe('/personality')
expect(slashCommandToken('model')).toBe('/model')
})
it('handles an empty query', () => {
expect(slashCommandToken('')).toBe('/')
})
})
describe('slashChipKindForItem', () => {
it('maps completion groups to chip kinds', () => {
expect(slashChipKindForItem(item('Skills'))).toBe('skill')
expect(slashChipKindForItem(item('Themes'))).toBe('theme')
expect(slashChipKindForItem(item('Commands'))).toBe('command')
})
})
describe('acceptsTriggerCompletion', () => {
const press = (key: string, overrides: Partial<TriggerAcceptInput> = {}) =>
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 <prose>` 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('implicitSlashAcceptIndex', () => {
const rows = ['/compress', '/review', '/resume']
it('completes a prefix of the highlighted row', () => {
expect(implicitSlashAcceptIndex('com', rows, 0, false)).toBe(0)
})
it('keeps a fully typed command even when another row is highlighted', () => {
expect(implicitSlashAcceptIndex('review', rows, 0, false)).toBe(1)
})
it('does not steal when the typed token is not a prefix of any row', () => {
expect(implicitSlashAcceptIndex('review', ['/compress', '/resume'], 0, false)).toBeNull()
})
it('takes the only prefix match when the highlight is a leftover', () => {
expect(implicitSlashAcceptIndex('rev', ['/compress', '/review', '/resume'], 0, false)).toBe(1)
})
it('honours an arrowed pick even when it is not a prefix', () => {
expect(implicitSlashAcceptIndex('review', rows, 0, true)).toBe(0)
})
it('matches an arg-stage prefix against the full completion text', () => {
expect(implicitSlashAcceptIndex('personality alic', ['/personality alice', '/personality none'], 0, false)).toBe(0)
})
})
describe('pickPlaceholder', () => {
it('returns a member of the pool', () => {
const pool = ['a', 'b', 'c'] as const
expect(pool).toContain(pickPlaceholder(pool))
})
})
describe('isPendingDraftPersistCurrent (#54527 integrity guard)', () => {
it('accepts a write when the pending entry still matches what was captured', () => {
const entry: PendingDraftPersist = { scope: 'session-a', text: 'hello' }
expect(isPendingDraftPersistCurrent(entry, entry)).toBe(true)
expect(isPendingDraftPersistCurrent({ scope: 'session-a', text: 'hello' }, entry)).toBe(true)
})
it('rejects when the pending slot was cleared (session swap / newer flush already committed)', () => {
const entry: PendingDraftPersist = { scope: 'session-a', text: 'hello' }
expect(isPendingDraftPersistCurrent(null, entry)).toBe(false)
})
it('rejects when the pending slot now belongs to a different session (the #54527 misroute shape)', () => {
const captured: PendingDraftPersist = { scope: 'session-a', text: 'carefully composed prompt' }
const supersededBy: PendingDraftPersist = { scope: 'session-b', text: 'different draft' }
expect(isPendingDraftPersistCurrent(supersededBy, captured)).toBe(false)
})
it('rejects when the pending slot was replaced by a newer keystroke in the same session', () => {
const captured: PendingDraftPersist = { scope: 'session-a', text: 'first draft' }
const supersededBy: PendingDraftPersist = { scope: 'session-a', text: 'first draft continued' }
expect(isPendingDraftPersistCurrent(supersededBy, captured)).toBe(false)
})
it('rejects when nothing was ever captured', () => {
expect(isPendingDraftPersistCurrent(null, null)).toBe(false)
})
})
@@ -0,0 +1,219 @@
import type { Unstable_TriggerItem } from '@assistant-ui/core'
import type { ConnectionState } from '@hermes/shared'
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
// for its chevron icon so the controls stop crowding the input before the whole
// row has to stack. Progressive collapse: full pill → icon pill → stacked.
//
// Sized off what the controls actually cost, because guessing put the two
// stages on top of each other. With the full pill the controls take ~284px
// (pill 111 + the icon cluster), so at the old 440 the inline input was ~156px
// — barely over its 128px minimum. A few words wrapped, wrapping is what
// stacks the row, and the pill's chevron arrived at the same moment the row
// gave up, which is the one thing progressive collapse is supposed to avoid.
// At 560 the label goes while the input still has ~276px, and the ~110px the
// chevron frees is spent keeping the row single for another stretch.
export const COMPOSER_COMPACT_PILL_PX = 560
// The ladder keeps going below the stack breakpoint — a pane can be far
// narrower than even the stacked controls row. Both rungs are budgeted
// against that row's real cost: menu ~24 + surface padding 16 + the cluster
// (~190; ~218 mid-turn with the queue button).
//
// At 260 the three voice toggles fold into the one menu HUD mode already
// uses, clearing the mid-turn worst case with margin. Each stage sits clear
// of the floor below it rather than arriving the instant the previous one
// gives out — the mistake COMPOSER_COMPACT_PILL_PX documents.
export const COMPOSER_FOLD_VOICE_PX = 260
// Type and send, nothing else. A pane can be dragged to MIN_PANE_PX (80), and
// even with voice folded the row still costs ~150, so the last rung drops the
// pill AND the voice menu. Both stay reachable — the model by hotkey and the
// full picker, dictation from any wider pane — and Send fits with room to
// spare at any width the layout tree allows (~74 all-in).
export const COMPOSER_MINIMAL_PX = 180
// A single editor line is ~28px (--composer-input-min-height 1.625rem + 0.5rem
// vertical padding). Anything taller means the text wrapped to a second line,
// which is when the composer should expand to the stacked layout.
export const COMPOSER_SINGLE_LINE_MAX_PX = 36
export const COMPOSER_FADE_BACKGROUND =
'linear-gradient(to bottom, transparent, color-mix(in srgb, var(--dt-background) 10%, transparent))'
// Quiet period after the last keystroke before persisting the draft;
// unmount/pagehide flushes bypass it.
export const DRAFT_PERSIST_DEBOUNCE_MS = 400
/**
* Keep a reconnecting draft editable so transient gateway dials cannot blur
* the editor and discard the user's caret. Submission still reads the
* independent `disabled` prop, so non-open states cannot send.
*
* An `open` state paired with `disabled=true` is a transient disagreement
* between the connection atoms; fail closed until they converge.
*/
export function shouldDisableComposerInput(disabled: boolean, gatewayState: ConnectionState): boolean {
return disabled && gatewayState === 'open'
}
export const pickPlaceholder = (pool: readonly string[]) => pool[Math.floor(Math.random() * pool.length)]
/** Completion items can carry an `action` (set in use-slash-completions) that
* runs a side effect on pick instead of inserting a chip — e.g. the session
* picker's "Browse all…" entry opens the overlay. Table-driven so new action
* items are a registry row, not a composer branch. */
export const COMPLETION_ACTIONS: Record<string, () => void> = {
'session-picker': () => setSessionPickerOpen(true)
}
/** Map a picked `/` completion to its pill accent. Driven by the completion
* group set in use-slash-completions (Skills / Themes / Commands|Options). */
export function slashChipKindForItem(item: Unstable_TriggerItem): SlashChipKind {
const group = (item.metadata as { group?: unknown } | undefined)?.group
if (group === 'Skills') {
return 'skill'
}
if (group === 'Themes') {
return 'theme'
}
return 'command'
}
/** True for a skill completion — the only kind offered mid-message. */
export const isSkillItem = (item: Unstable_TriggerItem) => slashChipKindForItem(item) === 'skill'
/** A `/` query is at its arg stage once it's past the command name. */
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() ?? ''}`
/** Typed `/` query or completion text without the leading slash. */
export const slashCompletionToken = (value: string) => value.replace(/^\//, '').trimEnd().toLowerCase()
/**
* Which row Space/Enter should take. Tab always uses the highlight; this is
* the "still suggesting, don't steal what I typed" pick.
*
* `/com` + a highlighted `/compress` still completes. `/review` while
* `/compress` is the leftover highlight does not. An exact list match wins
* so a fully typed command isn't replaced by a longer/fuzzier neighbour.
*/
export function implicitSlashAcceptIndex(
query: string,
itemTexts: readonly string[],
activeIndex: number,
activeExplicit: boolean
): number | null {
const typed = slashCompletionToken(query)
if (!typed) {
return null
}
if (activeExplicit && itemTexts[activeIndex] != null) {
return activeIndex
}
const exact = itemTexts.findIndex(text => slashCompletionToken(text) === typed)
if (exact >= 0) {
return exact
}
const active = itemTexts[activeIndex]
if (active != null && slashCompletionToken(active).startsWith(typed)) {
return activeIndex
}
const prefixHits = itemTexts.flatMap((text, index) => (slashCompletionToken(text).startsWith(typed) ? [index] : []))
return prefixHits.length === 1 ? prefixHits[0] : null
}
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
entryId: string
sessionKey: string
}
export const cloneAttachments = (attachments: ComposerAttachment[]) => attachments.map(a => ({ ...a }))
export interface PendingDraftPersist {
scope: string | null
text: string
}
/**
* Defense-in-depth for #54527: the debounce timer and the `pagehide` flush
* both write a captured `{ scope, text }` pair some time after it was
* scheduled. Before either commits the write, this checks the pair is still
* the one currently on file — i.e. nothing cleared or replaced it in the
* meantime (a session swap, a newer keystroke). The scope-capture fix
* upstream (`draftScopeRef`) already makes every captured pair correct by
* construction; this guard exists so that if a future change reintroduces a
* stale/live-ref read at one of these call sites, the write is dropped
* instead of silently filing one session's text under another session's key.
*/
export function isPendingDraftPersistCurrent(
pending: PendingDraftPersist | null,
expected: PendingDraftPersist | null
): boolean {
return pending !== null && expected !== null && pending.scope === expected.scope && pending.text === expected.text
}
@@ -0,0 +1,201 @@
import { useState } from 'react'
import { composerPanelCard } from '@/components/chat/composer-dock'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { Kbd } from '@/components/ui/kbd'
import { Tip } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
import { Clipboard, FileText, FolderOpen, type IconComponent, ImageIcon, Link, MessageSquareText } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { useComposerAttachmentProviders } from './contrib'
import { GHOST_ICON_BTN } from './controls'
import type { ChatBarState } from './types'
const SNIPPET_KEYS = ['codeReview', 'implementationPlan', 'explainThis']
export function ContextMenu({
state,
onInsertText,
onOpenUrlDialog,
onPasteClipboardImage,
onPickFiles,
onPickFolders,
onPickImages
}: ContextMenuProps) {
const { t } = useI18n()
const c = t.composer
// Prompt snippets used to be a Radix submenu. That submenu didn't open
// reliably when the parent menu was positioned at the bottom of the
// window (composer "+" anchor), so we promoted it to a real Dialog —
// easier to grow with search / descriptions, and no positioning math.
const [snippetsOpen, setSnippetsOpen] = useState(false)
// `composer.attachments` contributions — plugin/core-registered rows that
// extend this menu through the same registry as every other surface.
const attachmentProviders = useComposerAttachmentProviders()
return (
<>
<DropdownMenu>
<Tip label={state.tools.label} side="top">
<DropdownMenuTrigger asChild>
<Button
aria-label={state.tools.label}
className={cn(
GHOST_ICON_BTN,
'data-[state=open]:bg-(--chrome-action-hover) data-[state=open]:text-foreground'
)}
disabled={!state.tools.enabled}
size="icon"
type="button"
variant="ghost"
>
<Codicon name="add" size="0.875rem" />
</Button>
</DropdownMenuTrigger>
</Tip>
<DropdownMenuContent align="start" className={cn('w-60', composerPanelCard)} side="top" sideOffset={6}>
<DropdownMenuLabel className="px-2 pb-0.5 pt-0.5 text-[0.625rem] font-semibold uppercase tracking-wider text-(--ui-text-tertiary)">
{c.attachLabel}
</DropdownMenuLabel>
<ContextMenuItem disabled={!onPickFiles} icon={FileText} onSelect={onPickFiles}>
{c.files}
</ContextMenuItem>
<ContextMenuItem disabled={!onPickFolders} icon={FolderOpen} onSelect={onPickFolders}>
{c.folder}
</ContextMenuItem>
<ContextMenuItem disabled={!onPickImages} icon={ImageIcon} onSelect={onPickImages}>
{c.images}
</ContextMenuItem>
<ContextMenuItem
disabled={!onPasteClipboardImage}
icon={Clipboard}
onSelect={onPasteClipboardImage ? () => void onPasteClipboardImage() : undefined}
>
{c.pasteImage}
</ContextMenuItem>
<ContextMenuItem icon={Link} onSelect={onOpenUrlDialog}>
{c.url}
</ContextMenuItem>
<DropdownMenuSeparator />
<ContextMenuItem icon={MessageSquareText} onSelect={() => setSnippetsOpen(true)}>
{c.promptSnippets}
</ContextMenuItem>
{attachmentProviders.length > 0 && <DropdownMenuSeparator />}
{attachmentProviders.map(provider => (
<DropdownMenuItem
className="text-[length:var(--conversation-tool-font-size)] focus:bg-(--ui-bg-tertiary)"
key={provider.key}
onSelect={() => void provider.run({ insertText: onInsertText })}
>
<Codicon name={provider.icon ?? 'plug'} size="0.875rem" />
<span>{provider.label}</span>
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<div className="px-2 py-1 text-[0.7rem] text-muted-foreground/80">
{c.tipPre}
<Kbd size="sm">@</Kbd>
{c.tipPost}
</div>
</DropdownMenuContent>
</DropdownMenu>
<PromptSnippetsDialog onInsertText={onInsertText} onOpenChange={setSnippetsOpen} open={snippetsOpen} />
</>
)
}
function PromptSnippetsDialog({ onInsertText, onOpenChange, open }: PromptSnippetsDialogProps) {
const { t } = useI18n()
const c = t.composer
return (
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{c.snippetsTitle}</DialogTitle>
<DialogDescription>{c.snippetsDesc}</DialogDescription>
</DialogHeader>
<ul className="grid gap-1">
{SNIPPET_KEYS.map(key => {
const snippet = c.snippets[key]
return (
<li key={key}>
<button
className="group/snippet flex w-full cursor-pointer items-start gap-2.5 rounded-md border border-transparent px-2.5 py-2 text-left transition-colors hover:border-(--ui-stroke-tertiary) hover:bg-(--ui-control-hover-background) focus-visible:border-(--ui-stroke-tertiary) focus-visible:bg-(--ui-control-hover-background) focus-visible:outline-none"
onClick={() => {
onInsertText(snippet.text)
onOpenChange(false)
}}
type="button"
>
<MessageSquareText className="mt-0.5 size-3.5 shrink-0 text-(--ui-text-tertiary) group-hover/snippet:text-foreground" />
<span className="grid min-w-0 gap-0.5">
<span className="text-sm font-medium text-foreground">{snippet.label}</span>
<span className="text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
{snippet.description}
</span>
</span>
</button>
</li>
)
})}
</ul>
</DialogContent>
</Dialog>
)
}
export function ContextMenuItem({ children, disabled, icon: Icon, onSelect }: ContextMenuItemProps) {
return (
// Override font size + highlight to match the / · @ completion rows exactly.
<DropdownMenuItem
className="text-[length:var(--conversation-tool-font-size)] focus:bg-(--ui-bg-tertiary)"
disabled={disabled}
onSelect={onSelect}
>
<Icon />
<span>{children}</span>
</DropdownMenuItem>
)
}
interface ContextMenuItemProps {
children: string
disabled?: boolean
icon: IconComponent
onSelect?: () => void
}
interface ContextMenuProps {
onInsertText: (text: string) => void
onOpenUrlDialog: () => void
onPasteClipboardImage?: (opts?: { silent?: boolean }) => Promise<boolean> | void
onPickFiles?: () => void
onPickFolders?: () => void
onPickImages?: () => void
state: ChatBarState
}
interface PromptSnippetsDialogProps {
onInsertText: (text: string) => void
onOpenChange: (open: boolean) => void
open: boolean
}
@@ -0,0 +1,54 @@
import { afterEach, describe, expect, it } from 'vitest'
import { registry } from '@/contrib/registry'
import { COMPOSER_AREAS, type ComposerMiddleware, runComposerMiddleware } from './contrib'
const disposers: Array<() => void> = []
function addMiddleware(id: string, handler: ComposerMiddleware['handler'], order?: number) {
disposers.push(
registry.register({ id, area: COMPOSER_AREAS.middleware, order, data: { handler } satisfies ComposerMiddleware })
)
}
afterEach(() => {
disposers.splice(0).forEach(d => d())
})
describe('runComposerMiddleware', () => {
it('passes the draft through untouched when nothing is registered', async () => {
const draft = { text: 'hello' }
expect(await runComposerMiddleware(draft)).toBe(draft)
})
it('chains rewrites in registry order', async () => {
addMiddleware('b', d => ({ ...d, text: `${d.text}b` }), 20)
addMiddleware('a', d => ({ ...d, text: `${d.text}a` }), 10)
expect(await runComposerMiddleware({ text: 'x' })).toEqual({ text: 'xab' })
})
it('cancels the send when a handler returns null', async () => {
addMiddleware('gate', () => null)
addMiddleware('later', d => ({ ...d, text: 'never' }), 99)
expect(await runComposerMiddleware({ text: 'x' })).toBeNull()
})
it('treats a throwing handler as pass-through', async () => {
addMiddleware('boom', () => {
throw new Error('broken plugin')
})
addMiddleware('after', d => ({ ...d, text: `${d.text}!` }), 99)
expect(await runComposerMiddleware({ text: 'x' })).toEqual({ text: 'x!' })
})
it('supports async handlers', async () => {
addMiddleware('async', async d => ({ ...d, text: d.text.toUpperCase() }))
expect(await runComposerMiddleware({ text: 'quiet' })).toEqual({ text: 'QUIET' })
})
})
@@ -0,0 +1,161 @@
/**
* Composer contribution surface — every seam of the composer is hook-into-able
* through the SAME registry schema as every other surface (statusbar, titlebar,
* panes, layouts):
*
* render areas (`render`): composer.top — banner strip above the input
* composer.bottom — row below the input grid
* composer.underside — floating strip BELOW the
* whole composer (no chrome)
* composer.leading — inline after the "+" menu
* composer.actions — inline before the model pill
*
* data kinds (`data`): composer.middleware (ComposerMiddleware)
* composer.attachments (ComposerAttachmentProvider)
* composer.microActions (ComposerMicroActionProvider)
*
* Core keeps ownership of the transcript, input, and submit engine — these
* seams AUGMENT the composer, they never replace it. Middleware runs as an
* ordered async chain around the app's onSubmit: each handler may rewrite the
* draft, pass it through, or cancel the send by returning null.
*/
import { useMemo } from 'react'
import { useContributions } from '@/contrib/react/use-contributions'
import { registry } from '@/contrib/registry'
import type { TodoItem } from '@/lib/todos'
import type { ComposerAttachment } from '@/store/composer'
import type { ComposerAction } from '@/store/composer-actions'
export const COMPOSER_AREAS = {
top: 'composer.top',
bottom: 'composer.bottom',
underside: 'composer.underside',
leading: 'composer.leading',
actions: 'composer.actions',
middleware: 'composer.middleware',
attachments: 'composer.attachments',
microActions: 'composer.microActions',
atCompletions: 'composer.atCompletions'
} as const
export interface ComposerDraft {
text: string
attachments?: ComposerAttachment[]
}
/** Payload of a `composer.middleware` data contribution. */
export interface ComposerMiddleware {
/** Rewrite (return a draft), pass through (same draft), or cancel (null). */
handler: (draft: ComposerDraft) => ComposerDraft | null | Promise<ComposerDraft | null>
}
/** One row a `composer.atCompletions` source offers for the current query. */
export interface ComposerAtCompletionItem {
/** Text inserted into the draft when picked (e.g. `@researcher`). */
insert: string
/** Row label; defaults to `insert`. */
display?: string
/** Secondary line (e.g. "Bot · Homelab"). */
meta?: string
/** Icon slug understood by the completion popover; defaults to 'simple'. */
icon?: string
}
/** Payload of a `composer.atCompletions` data contribution — an extra source
* merged into the composer's `@` popover ABOVE the path/reference results.
* `query` is the text typed after `@` (no leading `@`). Sources must be
* fast and synchronous-ish (called per keystroke after the debounce); slow
* lookups belong behind the source's own cache. */
export interface ComposerAtCompletionSource {
provide: (query: string) => ComposerAtCompletionItem[]
}
export interface ComposerAttachmentContext {
insertText: (text: string) => void
}
/** Payload of a `composer.attachments` data contribution — an entry in the
* composer's "+" attach menu. */
export interface ComposerAttachmentProvider {
label: string
/** Codicon name for the menu row. Defaults to `plug`. */
icon?: string
run: (ctx: ComposerAttachmentContext) => void | Promise<void>
}
/**
* Run the ordered middleware chain over a draft. Contributions execute in
* registry order (`order`, then registration order); the first `null` wins
* and cancels the send. A throwing handler is treated as pass-through so a
* broken plugin can't eat messages.
*/
export async function runComposerMiddleware(draft: ComposerDraft): Promise<ComposerDraft | null> {
let current = draft
for (const contribution of registry.getArea(COMPOSER_AREAS.middleware)) {
const middleware = contribution.data as ComposerMiddleware | undefined
if (!middleware?.handler) {
continue
}
try {
const next = await middleware.handler(current)
if (next === null) {
return null
}
current = next
} catch {
// Pass-through: a faulty middleware must never swallow the message.
}
}
return current
}
/** Attach-menu entries contributed by plugins/core, with stable render keys. */
export function useComposerAttachmentProviders(): Array<ComposerAttachmentProvider & { key: string }> {
return useContributions(COMPOSER_AREAS.attachments)
.map(c => ({ key: `${c.source ?? 'core'}:${c.id}`, ...(c.data as ComposerAttachmentProvider) }))
.filter(p => Boolean(p.label && p.run))
}
/**
* Payload of a `composer.microActions` data contribution — the pill strip at
* the top of the composer's overlay lane.
*
* `resolve` is called with the live session context and returns the badges to
* show right now, or `[]` for "nothing from me". Returning a list rather than
* a static badge is what lets a provider be conditional ("only while idle",
* "only with unfinished tasks") without a reactive `when()`, which the
* registry deliberately doesn't offer.
*/
export interface ComposerMicroActionProvider {
resolve: (ctx: ComposerMicroActionContext) => ComposerAction[]
}
/** What a micro-action provider gets to branch on. Deliberately small: every
* field here is a standing compatibility promise to the plugins using it. */
export interface ComposerMicroActionContext {
/** A turn is currently running in this session. */
busy: boolean
sessionId: string
/** Live todo list for the session (empty when there is none). */
todos: readonly TodoItem[]
}
/** Micro-action providers, memoised against the registry's own stable
* snapshot — the strip re-resolves on every composer render, so a fresh array
* here would defeat that. */
export function useComposerMicroActionProviders(): ComposerMicroActionProvider[] {
const contributions = useContributions(COMPOSER_AREAS.microActions)
return useMemo(
() => contributions.map(c => c.data as ComposerMicroActionProvider).filter(p => typeof p?.resolve === 'function'),
[contributions]
)
}
@@ -0,0 +1,25 @@
import { cn } from '@/lib/utils'
// Shared class names for the composer's control row, in a module of their own
// so both the row (`controls.tsx`) and the menus it renders can wear them
// without importing each other in a cycle.
export const ICON_BTN = 'size-(--composer-control-size) shrink-0 rounded-md'
export const GHOST_ICON_BTN = cn(
ICON_BTN,
'text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground'
)
// Send/voice-conversation primary: solid foreground-on-background circle
// (reads as black-on-white in light mode, white-on-black in dark mode) to
// match the reference composer's high-contrast CTA. Keeps the pill itself
// neutral and lets the action visually dominate the row.
export const PRIMARY_ICON_BTN = cn(
'size-(--composer-control-primary-size,var(--composer-control-size)) shrink-0 rounded-full p-0',
'bg-foreground text-background hover:bg-foreground/90',
'disabled:bg-foreground/30 disabled:text-background disabled:opacity-100'
)
/** A toggle that is currently ON — dictation, spoken replies, the wake word. */
export const ACTIVE_ICON_BTN = 'bg-primary/10 text-primary hover:bg-primary/15 hover:text-primary'
@@ -0,0 +1,217 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { ChatBarState } from '@/app/chat/composer/types'
import { I18nProvider } from '@/i18n'
import { $hudMode } from '@/store/hud'
import { applyWakeStartResult, applyWakeStatus, resetWakeWordState } from '@/store/wake-word'
import { ComposerControls } from './controls'
vi.mock('./model-pill', () => ({ ModelPill: () => null }))
const state: ChatBarState = {
model: { canSwitch: false, model: '', provider: '' },
tools: { enabled: false, label: '' },
voice: { active: false, enabled: false }
}
function renderControls(overrides: Partial<React.ComponentProps<typeof ComposerControls>> = {}) {
return render(
<I18nProvider configClient={null} initialLocale="en">
<ComposerControls
autoSpeak={false}
busy={false}
busyAction="stop"
canSubmit={true}
conversation={{
active: false,
level: 0,
muted: false,
onEnd: vi.fn(),
onStart: vi.fn(),
onStopTurn: vi.fn(),
onToggleMute: vi.fn(),
status: 'idle'
}}
disabled={false}
hasComposerPayload={true}
onDictate={vi.fn()}
onQueue={vi.fn()}
onToggleAutoSpeak={vi.fn()}
state={state}
voiceStatus="idle"
{...overrides}
/>
</I18nProvider>
)
}
async function expectShortcutTooltip(label: string, shortcut: string) {
fireEvent.pointerMove(screen.getByLabelText(label), { pointerType: 'mouse' })
const tooltip = await screen.findByRole('tooltip')
expect(tooltip.textContent).toContain(label)
expect(tooltip.textContent).toContain(shortcut)
}
afterEach(() => {
cleanup()
$hudMode.set(false)
})
// The HUD is a Spotlight bar a few hundred pixels wide: the four voice
// controls fold into one menu there, and the way out of HUD mode joins the
// row instead of floating above the bar in a reserved strip. The docked
// composer keeps every control inline and shows no exit.
describe('HUD mode', () => {
it('keeps the voice controls inline and offers no exit in the docked composer', () => {
renderControls()
expect(screen.getByLabelText('Voice dictation')).toBeTruthy()
expect(screen.getByLabelText('Read replies aloud')).toBeTruthy()
expect(screen.queryByLabelText('Exit HUD mode')).toBeNull()
expect(screen.queryByLabelText('Reset HUD size and position')).toBeNull()
expect(screen.queryByLabelText('Voice')).toBeNull()
})
it('folds them into one menu and offers the way out in the HUD', () => {
$hudMode.set(true)
renderControls()
expect(screen.getByLabelText('Voice')).toBeTruthy()
expect(screen.getByLabelText('Reset HUD size and position')).toBeTruthy()
expect(screen.getByLabelText('Exit HUD mode')).toBeTruthy()
// Folded away, not duplicated — the whole point is the row's width back.
expect(screen.queryByLabelText('Voice dictation')).toBeNull()
expect(screen.queryByLabelText('Read replies aloud')).toBeNull()
})
// A collapsed menu that looked idle while the mic was open would be a worse
// trade than the space it saves, so the trigger reports the live state.
it('reports a live voice state on the collapsed trigger', () => {
$hudMode.set(true)
renderControls({ voiceStatus: 'recording' })
expect(screen.getByLabelText('Stop dictation')).toBeTruthy()
expect(screen.queryByLabelText('Voice')).toBeNull()
})
})
// A tile can be narrower than the controls cost, and the row is inside an
// overflow-hidden surface — so anything that doesn't fold gets clipped off the
// right edge, send button first. The ladder keeps going past `stacked`: voice
// folds into the same menu the HUD uses, then the model pill drops. Send is
// the last thing standing.
describe('narrow tiles', () => {
it('folds the voice controls into one menu without entering HUD mode', () => {
renderControls({ foldVoice: true })
expect(screen.getByLabelText('Voice')).toBeTruthy()
expect(screen.queryByLabelText('Voice dictation')).toBeNull()
expect(screen.queryByLabelText('Read replies aloud')).toBeNull()
// Folding is a width decision, not the HUD: no exit affordance appears.
expect(screen.queryByLabelText('Exit HUD mode')).toBeNull()
})
it('keeps Send at the tightest width, with everything else dropped', () => {
renderControls({ foldVoice: true, minimal: true })
expect(screen.getByLabelText('Send')).toBeTruthy()
expect(screen.queryByLabelText('Voice')).toBeNull()
})
it('keeps Stop reachable mid-turn at the tightest width', () => {
renderControls({ busy: true, busyAction: 'stop', foldVoice: true, hasComposerPayload: false, minimal: true })
expect(screen.getByLabelText('Stop')).toBeTruthy()
})
})
describe('ComposerControls shortcut tooltips', () => {
it('shows Enter for Send', async () => {
renderControls()
await expectShortcutTooltip('Send', '↵')
})
it('keeps Send (not Steer) while a turn is running if there is a payload', async () => {
renderControls({ busy: true, busyAction: 'steer' })
await expectShortcutTooltip('Send', '↵')
})
it('shows Stop only when the composer is empty mid-turn', async () => {
renderControls({ busy: true, busyAction: 'stop', canSubmit: true, hasComposerPayload: false })
await expectShortcutTooltip('Stop', '↵')
})
it('shows Ctrl+Enter for Queue as the secondary mid-turn action', async () => {
renderControls({ busy: true, busyAction: 'queue' })
await expectShortcutTooltip('Queue message', 'Ctrl+↵')
})
})
describe('wake-word ear visibility', () => {
afterEach(() => {
resetWakeWordState()
})
it('stays mounted during a busy agent turn', () => {
applyWakeStatus({ available: true, enabled: true, listening: true, phrase: 'hey hermes' })
renderControls({ busy: true, busyAction: 'stop' })
expect(screen.getByLabelText('Wake word: "hey hermes" — listening')).toBeTruthy()
})
it('stays mounted (enabled in config) even when a start was refused', () => {
applyWakeStatus({ available: true, enabled: true, listening: false, phrase: 'hey hermes' })
// Transient refusal marks available false but enabled keeps it mounted.
applyWakeStartResult({ hint: 'mic busy', reason: 'unavailable', started: false })
renderControls()
expect(screen.getByLabelText('Wake word: "hey hermes" — off')).toBeTruthy()
})
it('stays visible (never hides) even when unavailable and not enabled', () => {
applyWakeStatus({ available: false, enabled: false, listening: false, phrase: 'hey hermes' })
renderControls()
// The ear ALWAYS shows so the user can click to enable; a failed start
// surfaces its reason in the tooltip rather than hiding the control.
expect(screen.getByLabelText('Wake word: "hey hermes" — off')).toBeTruthy()
})
it('surfaces the backend refusal reason in the tooltip, still visible', () => {
applyWakeStatus({ available: false, enabled: false, listening: false, phrase: 'hey hermes' })
applyWakeStartResult({ hint: 'run `hermes tools` (Voice section)', reason: 'unavailable', started: false })
renderControls()
const ear = screen.getByLabelText('Wake word: "hey hermes" — off')
expect(ear).toBeTruthy()
})
it('shows a disabled paused ear inside the voice-conversation pill', () => {
applyWakeStatus({ available: true, enabled: true, listening: true, phrase: 'hey hermes' })
renderControls({
conversation: {
active: true,
level: 0,
muted: false,
onEnd: vi.fn(),
onStart: vi.fn(),
onStopTurn: vi.fn(),
onToggleMute: vi.fn(),
status: 'listening'
}
})
const ear = screen.getByLabelText('Wake word: "hey hermes" — paused during voice chat')
expect((ear as HTMLButtonElement).disabled).toBe(true)
})
})
@@ -0,0 +1,448 @@
import { useStore } from '@nanostores/react'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { Tip, TipKeybindLabel } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { AudioLines, Ear, EarOff, iconSize, Layers3, Loader2, Square, Volume2, VolumeX } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { $hudMode, closeHud, resetHudLayout } from '@/store/hud'
import { $wakeWord, toggleWakeWord } from '@/store/wake-word'
import { ACTIVE_ICON_BTN, GHOST_ICON_BTN, PRIMARY_ICON_BTN } from './control-classes'
import type { ConversationStatus } from './hooks/use-voice-conversation'
import { ModelPill } from './model-pill'
import type { ChatBarState, VoiceStatus } from './types'
import { VoiceMenu } from './voice-menu'
// Re-exported: `context-menu.tsx` and other row neighbours have always reached
// for these here, and the row is where they read as belonging.
export { ACTIVE_ICON_BTN, GHOST_ICON_BTN, ICON_BTN, PRIMARY_ICON_BTN } from './control-classes'
interface ConversationProps {
active: boolean
level: number
muted: boolean
status: ConversationStatus
onEnd: () => void
onStart: () => void
onStopTurn: () => void
onToggleMute: () => void
}
export function ComposerControls({
autoSpeak,
busy,
busyAction,
canSubmit,
compactModelPill = false,
conversation,
disabled,
foldVoice = false,
hasComposerPayload,
minimal = false,
state,
voiceStatus,
onDictate,
onQueue,
onToggleAutoSpeak
}: {
autoSpeak: boolean
busy: boolean
busyAction: 'steer' | 'queue' | 'stop'
canSubmit: boolean
compactModelPill?: boolean
conversation: ConversationProps
disabled: boolean
foldVoice?: boolean
hasComposerPayload: boolean
minimal?: boolean
state: ChatBarState
voiceStatus: VoiceStatus
onDictate: () => void
onQueue: () => void
onToggleAutoSpeak: () => void
}) {
const { t } = useI18n()
const c = t.composer
const hudMode = useStore($hudMode)
if (conversation.active) {
return <ConversationPill {...conversation} disabled={disabled} />
}
const showVoicePrimary = !busy && !hasComposerPayload
// Steer is just send: a payload keeps the Send affordance mid-turn. Stop
// only when the composer is empty and a turn is running.
const showStop = busy && !hasComposerPayload
const showQueueButton = busyAction !== 'stop' && hasComposerPayload
// The HUD is a Spotlight bar a few hundred pixels wide, so the four separate
// voice toggles fold into one menu there and leave the row to the input. A
// narrow tile hits the same wall from the other direction and folds for the
// same reason — same controls, same state, different budget. Below that
// even the menu goes: at `minimal` the row is the send button and nothing
// else, which is the one thing that must survive every width.
const foldedVoice = hudMode || foldVoice
const voiceControls = foldedVoice ? (
<VoiceMenu
autoSpeak={autoSpeak}
disabled={disabled}
onDictate={onDictate}
onStartConversation={conversation.onStart}
onToggleAutoSpeak={onToggleAutoSpeak}
state={state}
voiceStatus={voiceStatus}
/>
) : (
<>
<DictationButton disabled={disabled} onToggle={onDictate} state={state.voice} status={voiceStatus} />
<AutoSpeakButton active={autoSpeak} disabled={disabled} onToggle={onToggleAutoSpeak} />
<WakeWordButton disabled={disabled} />
</>
)
return (
<div className="ml-auto flex min-w-0 shrink items-center gap-(--composer-control-gap)">
{minimal ? null : (
<>
<ModelPill compact={compactModelPill} disabled={disabled} model={state.model} />
{voiceControls}
</>
)}
{showQueueButton ? (
<Tip label={<TipKeybindLabel actionId="composer.queue" text={c.queueMessage} />}>
<Button
aria-label={c.queueMessage}
className={GHOST_ICON_BTN}
disabled={disabled}
onClick={onQueue}
size="icon"
type="button"
variant="ghost"
>
<Layers3 className={iconSize.sm} />
</Button>
</Tip>
) : null}
{showVoicePrimary ? (
<Tip label={c.startVoice}>
<Button
aria-label={c.startVoice}
className={PRIMARY_ICON_BTN}
disabled={disabled}
onClick={() => {
triggerHaptic('open')
conversation.onStart()
}}
size="icon"
type="button"
>
<AudioLines className={iconSize.sm} />
</Button>
</Tip>
) : (
<Tip
label={
showStop ? (
<TipKeybindLabel actionId="composer.send" text={c.stop} />
) : (
<TipKeybindLabel actionId="composer.send" text={c.send} />
)
}
>
<Button
aria-label={showStop ? c.stop : c.send}
className={PRIMARY_ICON_BTN}
disabled={disabled || !canSubmit}
type="submit"
>
{showStop ? (
<span className="block size-2.5 rounded-[0.1875rem] bg-current" />
) : (
<Codicon name="arrow-up" size="0.875rem" />
)}
</Button>
</Tip>
)}
{/* The way out of HUD mode, riding the controls row rather than floating
above the bar. The old chip lived in a 26px transparent strip reserved
over the composer (--hud-chip-strip), which under glass is bare
untinted material with a hidden button in it — a band of chrome above
the surface, paid for in every state, for a control that is invisible
until hovered. Here it costs no reserved space and sits with the other
things you can press. */}
{hudMode ? <HudWindowButtons /> : null}
</div>
)
}
function HudWindowButtons() {
const { t } = useI18n()
return (
<>
<Tip label={t.titlebar.resetHudLayout}>
<Button
aria-label={t.titlebar.resetHudLayout}
className={cn(GHOST_ICON_BTN, 'p-0')}
onClick={resetHudLayout}
size="icon"
type="button"
variant="ghost"
>
<Codicon name="discard" size="0.875rem" />
</Button>
</Tip>
<Tip label={t.titlebar.exitHud}>
<Button
aria-label={t.titlebar.exitHud}
className={cn(GHOST_ICON_BTN, 'p-0')}
onClick={closeHud}
size="icon"
type="button"
variant="ghost"
>
<Codicon name="screen-normal" size="0.875rem" />
</Button>
</Tip>
</>
)
}
function ConversationPill({
disabled,
level,
muted,
onEnd,
onStopTurn,
onToggleMute,
status
}: ConversationProps & { disabled: boolean }) {
const { t } = useI18n()
const c = t.composer
const speaking = status === 'speaking'
const listening = status === 'listening' && !muted
const label =
status === 'speaking'
? c.speaking
: status === 'transcribing'
? c.transcribing
: status === 'thinking'
? c.thinking
: muted
? c.muted
: c.listening
return (
<div className="ml-auto flex shrink-0 items-center gap-(--composer-control-gap)">
{/* Keep the ear visible during voice chat — shown paused, since the
conversation holds the mic (the one time wake must not listen). */}
<WakeWordButton disabled={disabled} pausedForVoice />
<Tip label={muted ? c.unmuteMic : c.muteMic}>
<Button
aria-label={muted ? c.unmuteMic : c.muteMic}
aria-pressed={muted}
className={cn(GHOST_ICON_BTN, 'p-0', muted && 'bg-muted text-muted-foreground')}
disabled={disabled}
onClick={() => {
triggerHaptic('selection')
onToggleMute()
}}
size="icon"
type="button"
variant="ghost"
>
<Codicon name={muted ? 'mic-off' : 'mic'} size="1rem" />
</Button>
</Tip>
{listening && (
<Button
aria-label={c.stopListening}
className="h-(--composer-control-size) shrink-0 gap-1.5 rounded-full px-2.5 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
disabled={disabled}
onClick={() => {
triggerHaptic('submit')
onStopTurn()
}}
type="button"
variant="ghost"
>
<Square className={cn('fill-current', iconSize.xs)} />
<span>{c.stopShort}</span>
</Button>
)}
<Button
aria-label={c.endConversation}
className="h-(--composer-control-size) gap-1.5 rounded-full bg-primary px-3 text-xs font-medium text-primary-foreground hover:bg-primary/90"
disabled={disabled}
onClick={() => {
triggerHaptic('close')
onEnd()
}}
type="button"
>
<ConversationIndicator level={level} listening={listening} speaking={speaking} />
<span>{c.endShort}</span>
</Button>
<span className="sr-only" role="status">
{label}
</span>
</div>
)
}
function ConversationIndicator({
level,
listening,
speaking
}: {
level: number
listening: boolean
speaking: boolean
}) {
if (speaking) {
return <Loader2 className={cn('animate-spin', iconSize.xs)} />
}
const bars = [0.55, 0.85, 1, 0.85, 0.55]
const normalized = Math.max(0, Math.min(level, 1))
return (
<span aria-hidden="true" className="flex h-3 items-center gap-0.5">
{bars.map((weight, index) => {
const height = listening ? 0.3 + Math.min(0.7, normalized * weight) : 0.3
return <span className="w-0.5 rounded-full bg-current" key={index} style={{ height: `${height * 100}%` }} />
})}
</span>
)
}
// Pure-TTS toggle: type normally, but have every assistant reply read aloud —
// no dictation, no full conversation loop. Filled/accent when on, mirroring the
// muted-mic pressed state above. Driven by (and persisted to) `voice.auto_tts`.
function AutoSpeakButton({ active, disabled, onToggle }: { active: boolean; disabled: boolean; onToggle: () => void }) {
const { t } = useI18n()
const c = t.composer
const label = active ? c.stopSpeakingReplies : c.speakReplies
return (
<Tip label={label}>
<Button
aria-label={label}
aria-pressed={active}
className={cn(GHOST_ICON_BTN, 'p-0', active && ACTIVE_ICON_BTN)}
disabled={disabled}
onClick={() => {
triggerHaptic(active ? 'close' : 'open')
onToggle()
}}
size="icon"
type="button"
variant="ghost"
>
{active ? <Volume2 className={iconSize.sm} /> : <VolumeX className={iconSize.sm} />}
</Button>
</Tip>
)
}
// "Hey Hermes" wake-word toggle. ALWAYS rendered — the ear never hides. A
// user must always be able to click it to turn passive listening on; if the
// backend can't start (missing STT/TTS, deps still installing, no mic
// permission, etc.) the click surfaces the reason in the tooltip and the
// toggle stays off. States: listening (accent-highlighted), off (muted
// ear-off), and paused-for-voice (disabled while a voice conversation holds
// the mic — the one time wake genuinely must not listen). Backend refusals
// ({started:false, reason}) keep the toggle off and put the reason/hint in
// the tooltip.
function WakeWordButton({ disabled, pausedForVoice = false }: { disabled: boolean; pausedForVoice?: boolean }) {
const { t } = useI18n()
const c = t.composer
const wake = useStore($wakeWord)
const phrase = wake.phrase || 'hey hermes'
const label = pausedForVoice
? c.wakeWordPausedVoice(phrase)
: wake.listening
? c.wakeWordListening(phrase)
: c.wakeWordOff(phrase)
const tooltip = !pausedForVoice && wake.notice ? `${label} — ${wake.notice}` : label
return (
<Tip label={tooltip}>
<Button
aria-label={label}
aria-pressed={wake.listening && !pausedForVoice}
className={cn(GHOST_ICON_BTN, 'p-0', wake.listening && !pausedForVoice && ACTIVE_ICON_BTN)}
disabled={disabled || pausedForVoice || wake.pending}
onClick={() => {
triggerHaptic(wake.listening ? 'close' : 'open')
void toggleWakeWord()
}}
size="icon"
type="button"
variant="ghost"
>
{wake.listening && !pausedForVoice ? <Ear className={iconSize.sm} /> : <EarOff className={iconSize.sm} />}
</Button>
</Tip>
)
}
function DictationButton({
disabled,
state,
status,
onToggle
}: {
disabled: boolean
state: ChatBarState['voice']
status: VoiceStatus
onToggle: () => void
}) {
const { t } = useI18n()
const c = t.composer
const active = state.active || status !== 'idle'
const aria =
status === 'recording' ? c.stopDictation : status === 'transcribing' ? c.transcribingDictation : c.voiceDictation
return (
<Tip label={aria}>
<Button
aria-label={aria}
aria-pressed={active}
className={cn(
GHOST_ICON_BTN,
'p-0',
'data-[active=true]:bg-accent data-[active=true]:text-foreground',
status === 'recording' && ACTIVE_ICON_BTN,
status === 'transcribing' && 'bg-primary/10 text-primary'
)}
data-active={active}
disabled={disabled || !state.enabled || status === 'transcribing'}
onClick={() => {
triggerHaptic(active ? 'close' : 'open')
onToggle()
}}
size="icon"
type="button"
variant="ghost"
>
{status === 'recording' ? (
<Square className={cn('fill-current', iconSize.xs)} />
) : status === 'transcribing' ? (
<Loader2 className={cn('animate-spin', iconSize.sm)} />
) : (
<Codicon name="mic" size="0.875rem" />
)}
</Button>
</Tip>
)
}
@@ -0,0 +1,153 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { I18nProvider } from '@/i18n'
import { $previewTabs, closeRightRail } from '@/store/preview'
import { ComposerDirectiveActions } from './directive-actions'
import { refChipElement } from './rich-editor'
const desktopWindow = window as unknown as { hermesDesktop?: Window['hermesDesktop'] }
const openSession = vi.fn()
vi.mock('@/app/open-session', () => ({ openSession: (...args: unknown[]) => openSession(...args) }))
/** A live contenteditable holding real chips, with the watcher bound to it —
* the same pair both composers mount. */
function mountEditor(chips: { kind: string; value: string }[]) {
const editor = document.createElement('div')
editor.contentEditable = 'true'
editor.append(...chips.map(chip => refChipElement(chip.kind, `\`${chip.value}\``)))
document.body.append(editor)
render(
<I18nProvider configClient={null} initialLocale="en">
<ComposerDirectiveActions editorRef={{ current: editor }} />
</I18nProvider>
)
return editor
}
function chips(editor: HTMLElement, kind: string) {
return Array.from(editor.querySelectorAll(`[data-ref-kind="${kind}"]`))
}
function hover(node: Element) {
fireEvent.pointerOver(node, { bubbles: true })
}
/** The reference the visible action pill points at, or null when there is none. */
function pillValue() {
return document.querySelector('[data-slot="composer-directive-action"]')?.getAttribute('data-value') ?? null
}
afterEach(() => {
cleanup()
document.body.replaceChildren()
closeRightRail()
delete desktopWindow.hermesDesktop
openSession.mockReset()
vi.useRealTimers()
})
describe('ComposerDirectiveActions', () => {
it('offers an action for a hovered actionable chip', () => {
const editor = mountEditor([{ kind: 'url', value: 'https://example.com/docs' }])
expect(pillValue()).toBeNull()
hover(chips(editor, 'url')[0]!)
expect(pillValue()).toBe('https://example.com/docs')
})
it('opens a url in the in-app browser rather than navigating the app', async () => {
const openExternal = vi.fn().mockResolvedValue(undefined)
desktopWindow.hermesDesktop = { openExternal } as unknown as Window['hermesDesktop']
const editor = mountEditor([{ kind: 'url', value: 'https://example.com/docs' }])
hover(chips(editor, 'url')[0]!)
fireEvent.click(screen.getByRole('button'))
expect(openExternal).not.toHaveBeenCalled()
await vi.waitFor(() => expect($previewTabs.get().at(-1)?.target.url).toBe('https://example.com/docs'))
expect(pillValue()).toBeNull()
})
it('runs the kind-specific action — a session chip opens the session', async () => {
const editor = mountEditor([{ kind: 'session', value: 'default/20260722_204335_d62c16' }])
hover(chips(editor, 'session')[0]!)
fireEvent.click(screen.getByRole('button'))
// openSessionRef lazy-imports the navigator, so the call lands a tick later.
await vi.waitFor(() =>
expect(openSession).toHaveBeenCalledWith('20260722_204335_d62c16', expect.any(Function), 'tab')
)
})
it('leaves kinds with no action alone', () => {
const editor = mountEditor([{ kind: 'file', value: 'src/main.tsx' }])
hover(chips(editor, 'file')[0]!)
expect(pillValue()).toBeNull()
})
it('follows the pointer from one chip to the next', () => {
const editor = mountEditor([
{ kind: 'url', value: 'https://one.example' },
{ kind: 'url', value: 'https://two.example' }
])
const [first, second] = chips(editor, 'url')
hover(first!)
expect(pillValue()).toBe('https://one.example')
hover(second!)
expect(pillValue()).toBe('https://two.example')
})
it('keeps the pill up while the pointer crosses onto it', () => {
vi.useFakeTimers()
const editor = mountEditor([{ kind: 'url', value: 'https://example.com' }])
const chip = chips(editor, 'url')[0]!
hover(chip)
fireEvent.pointerOut(chip, { relatedTarget: document.body })
fireEvent.mouseEnter(screen.getByRole('button').parentElement!)
vi.advanceTimersByTime(500)
expect(pillValue()).toBe('https://example.com')
})
it('binds to the document so a late-attached editor still gets the affordance', () => {
// The edit composer's editor isn't reliably in the DOM when the effect
// first runs; a document listener that reads the editor lazily works
// regardless — this is the whole reason it binds to document, not editor.
const editor = document.createElement('div')
editor.contentEditable = 'true'
editor.append(refChipElement('url', '`https://late.example`'))
render(
<I18nProvider configClient={null} initialLocale="en">
<ComposerDirectiveActions editorRef={{ current: editor }} />
</I18nProvider>
)
// Editor attached AFTER mount.
document.body.append(editor)
hover(editor.querySelector('[data-ref-kind="url"]')!)
expect(pillValue()).toBe('https://late.example')
})
})
@@ -0,0 +1,154 @@
/**
* Hover actions for directive chips in a composer.
*
* A directive chip (`@url:`, `@session:`, …) reads as the thing it points at
* and is coloured like one, but a composer is an editor — a click inside the
* contenteditable only places the caret, so there's no way to *act* on the
* reference. Instead, hovering a chip whose kind has an action floats a small
* pill above it that runs it.
*
* The kind → action table (`DIRECTIVE_ACTIONS`) lives in `directive-text`, so
* it is shared with the sent-message chip: one entry lights up both surfaces.
*/
import { type RefObject, useCallback, useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { DIRECTIVE_ACTIONS, type DirectiveAction } from '@/components/assistant-ui/directive-text'
import { composerFloatingPill } from '@/components/chat/composer-dock'
import { Codicon } from '@/components/ui/codicon'
import { useI18n } from '@/i18n'
import { cn } from '@/lib/utils'
/** Moving between the chip and the pill crosses a gap where neither is hovered.
* Short enough that it still reads as instant on the way out. */
const HIDE_DELAY_MS = 120
/** The actionable directive chip under `target` that also belongs to `editor`,
* if there is one. */
function actionableChipAt(target: EventTarget | null, editor: HTMLElement): HTMLElement | null {
const chip = target instanceof Element ? target.closest<HTMLElement>('[data-ref-kind]') : null
const kind = chip?.dataset.refKind
return chip && kind && chip.dataset.refId && editor.contains(chip) && DIRECTIVE_ACTIONS[kind] ? chip : null
}
interface Anchor {
action: DirectiveAction
chip: HTMLElement
left: number
top: number
value: string
}
function anchorFor(chip: HTMLElement): Anchor | null {
const value = chip.dataset.refId
const action = chip.dataset.refKind ? DIRECTIVE_ACTIONS[chip.dataset.refKind] : undefined
if (!value || !action || !chip.isConnected) {
return null
}
const rect = chip.getBoundingClientRect()
return { action, chip, left: rect.left, top: rect.top, value }
}
/**
* Renders the action pill for whichever actionable chip in `editorRef` is
* hovered.
*
* Listeners bind to `document`, not the editor, so mount timing can't strand
* them: the edit composer's contenteditable isn't reliably attached when this
* effect first runs, and a document listener that reads the editor lazily works
* regardless. Each instance filters to its own editor, so the docked and edit
* composers never show two pills for one chip.
*/
export function ComposerDirectiveActions({ editorRef }: { editorRef: RefObject<HTMLElement | null> }) {
const { t } = useI18n()
const [anchor, setAnchor] = useState<Anchor | null>(null)
const hideTimerRef = useRef<number | undefined>(undefined)
const cancelHide = useCallback(() => {
window.clearTimeout(hideTimerRef.current)
}, [])
const hideSoon = useCallback(() => {
cancelHide()
hideTimerRef.current = window.setTimeout(() => setAnchor(null), HIDE_DELAY_MS)
}, [cancelHide])
useEffect(() => {
const onPointerOver = (event: PointerEvent) => {
const editor = editorRef.current
const chip = editor && actionableChipAt(event.target, editor)
if (!chip) {
return
}
cancelHide()
setAnchor(current => (current?.chip === chip ? current : anchorFor(chip)))
}
const onPointerOut = (event: PointerEvent) => {
const editor = editorRef.current
const chip = editor && actionableChipAt(event.target, editor)
// A move within the same chip (its icon → its label) is not a leave.
if (chip && editor && chip === actionableChipAt(event.relatedTarget, editor)) {
return
}
hideSoon()
}
// The chip can move or vanish under a parked pointer: the editor scrolls,
// the window resizes, or the user deletes the reference the pill points at.
const reanchor = () => setAnchor(current => (current ? anchorFor(current.chip) : null))
document.addEventListener('pointerover', onPointerOver)
document.addEventListener('pointerout', onPointerOut)
window.addEventListener('scroll', reanchor, true)
window.addEventListener('resize', reanchor)
return () => {
document.removeEventListener('pointerover', onPointerOver)
document.removeEventListener('pointerout', onPointerOut)
window.removeEventListener('scroll', reanchor, true)
window.removeEventListener('resize', reanchor)
window.clearTimeout(hideTimerRef.current)
}
}, [cancelHide, editorRef, hideSoon])
if (!anchor) {
return null
}
return createPortal(
<div
className="fixed z-(--z-over-modal) -translate-y-full pb-1"
data-slot="composer-directive-action"
data-value={anchor.value}
onMouseEnter={cancelHide}
onMouseLeave={hideSoon}
style={{ left: anchor.left, top: anchor.top }}
>
<button
className={cn(composerFloatingPill, 'shadow-nous')}
onClick={() => {
anchor.action.run(anchor.value)
setAnchor(null)
}}
// Never let the press reach the editor: mousedown inside a
// contenteditable moves the caret and can collapse a selection the user
// still wants, and the edit composer treats a blur as "cancel".
onMouseDown={event => event.preventDefault()}
type="button"
>
<Codicon className="shrink-0 opacity-70" name={anchor.action.icon} size="0.75rem" />
{anchor.action.label(t)}
</button>
</div>,
document.body
)
}
@@ -0,0 +1,147 @@
import type { Unstable_TriggerItem } from '@assistant-ui/core'
import { act, renderHook } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { hermesDirectiveFormatter } from '@/components/assistant-ui/directive-text'
import { classify } from './hooks/use-at-completions'
import { useComposerTrigger } from './hooks/use-composer-trigger'
import { composerPlainText, RICH_INPUT_SLOT } from './rich-editor'
/** A row exactly as tui_gateway's complete.path emits it, run through the
* real classify() the popover uses. */
function backendRow(text: string, display: string, meta: string): Unstable_TriggerItem {
const c = classify({ text, display, meta })
return {
id: `${text}|0`,
type: c.type,
label: c.display,
metadata: { icon: c.type, display: c.display, meta: c.meta, rawText: text, insertId: c.insertId }
}
}
function typed(text: string) {
const editor = document.createElement('div')
editor.contentEditable = 'true'
editor.dataset.slot = RICH_INPUT_SLOT
document.body.append(editor)
editor.append(document.createTextNode(text))
const range = document.createRange()
range.selectNodeContents(editor)
range.collapse(false)
const sel = window.getSelection()
sel?.removeAllRanges()
sel?.addRange(range)
const editorRef = { current: editor as HTMLDivElement | null }
const { result } = renderHook(() =>
useComposerTrigger({
at: { adapter: null, loading: false },
draftRef: { current: text },
editorRef,
requestMainFocus: vi.fn(),
setComposerText: vi.fn(),
slash: { adapter: null, loading: false }
})
)
act(() => result.current.refreshTrigger())
return { editor, result }
}
/** The label the sent message renders for a committed draft. */
function sentLabel(draft: string) {
return hermesDirectiveFormatter
.parse(draft)
.filter((s): s is Extract<typeof s, { kind: 'mention' }> => s.kind === 'mention')
.map(s => s.label)
.join(',')
}
describe('one label per reference, on every surface', () => {
it('the popover row, the committed chip, and the sent chip all read the same', () => {
const cases = [
{ text: '@folder:apps/desktop/', display: 'desktop/', meta: 'dir' },
{ text: '@file:apps/desktop/src/main.tsx', display: 'main.tsx', meta: 'apps/desktop/src' },
{ text: '@folder:apps/desktop/src/', display: 'src/', meta: 'dir' }
]
for (const entry of cases) {
const item = backendRow(entry.text, entry.display, entry.meta)
const { editor, result } = typed('@desk')
act(() => result.current.replaceTriggerWithChip(item))
const row = String((item.metadata as { display: string }).display)
const chip = editor.querySelector('[data-ref-text]')?.textContent ?? ''
expect(chip).toBe(row)
expect(sentLabel(composerPlainText(editor))).toBe(row)
}
})
it('an agent mention inserts as plain @name text, never an @simple: chip', () => {
// Rows exactly as complete.path emits profile mentions (colon-less text).
const item = backendRow('@mr-tester', '@mr-tester', 'agent profile')
expect(hermesDirectiveFormatter.serialize(item)).toBe('@mr-tester')
const { editor, result } = typed('@mr-')
act(() => result.current.replaceTriggerWithChip(item))
const plain = composerPlainText(editor)
expect(plain).toContain('@mr-tester')
expect(plain).not.toContain('@simple:')
})
it('a folder pick reads as its path, not a bare basename', () => {
// `src` and `desktop` repeat all over a repo — the row you picked said
// where it was, and the chip has to keep saying it.
const item = backendRow('@folder:apps/desktop/', 'desktop/', 'dir')
expect(item.label).toBe('apps/desktop/')
const { editor, result } = typed('@desk')
act(() => result.current.replaceTriggerWithChip(item))
expect(editor.querySelector('[data-ref-text]')?.textContent).toBe('apps/desktop/')
})
it('Tab-descend leaves the live query, and the scope when there is one', () => {
const { editor, result } = typed('@folder:desk')
act(() =>
result.current.replaceTriggerWithChip(backendRow('@folder:apps/desktop/', 'desktop/', 'dir'), {
descend: true
})
)
// Mid-browse the editor holds the live query, scope included — that's the
// path being typed, not a label, and it's what the next completion reads.
expect(composerPlainText(editor)).toBe('@folder:apps/desktop/')
})
it('a url still reads host + path on every surface', () => {
const item = backendRow('@url:https://github.com/NousResearch/hermes-agent/pull/74533', '', '')
const { editor, result } = typed('@gith')
act(() => result.current.replaceTriggerWithChip(item))
const expected = 'github.com/NousResearch/hermes-agent/pull/74533'
expect(item.label).toBe(expected)
expect(editor.querySelector('[data-ref-text]')?.textContent).toBe(expected)
expect(sentLabel(composerPlainText(editor))).toBe(expected)
})
})
@@ -0,0 +1,164 @@
import type { Unstable_TriggerItem } from '@assistant-ui/core'
import { act, renderHook } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { useComposerTrigger } from './hooks/use-composer-trigger'
import { pathifyRefs } from './path-refs'
import { composerPlainText, insertComposerContentsAtCaret, RICH_INPUT_SLOT } from './rich-editor'
import { detectTrigger, openDirectiveScope, textBeforeCaret } from './text-utils'
import { linkifyUrls } from './url-refs'
function folderItem(rel: string): Unstable_TriggerItem {
const rawText = `@folder:${rel}/`
return {
id: `${rawText}|0`,
type: 'folder',
label: rel.split('/').filter(Boolean).pop() ?? rel,
metadata: { icon: 'folder', display: `${rel}/`, meta: 'dir', rawText, insertId: `${rel}/` }
}
}
/** Literally-typed text, caret `fromEnd` characters before the end. */
function typed(text: string, fromEnd = 0) {
const editor = document.createElement('div')
editor.contentEditable = 'true'
editor.dataset.slot = RICH_INPUT_SLOT
document.body.append(editor)
const node = document.createTextNode(text)
editor.append(node)
const range = document.createRange()
range.setStart(node, text.length - fromEnd)
range.collapse(true)
const sel = window.getSelection()
sel?.removeAllRanges()
sel?.addRange(range)
return editor
}
function withTrigger(editor: HTMLDivElement, draft: string) {
const editorRef = { current: editor as HTMLDivElement | null }
const { result } = renderHook(() =>
useComposerTrigger({
at: { adapter: null, loading: false },
draftRef: { current: draft },
editorRef,
requestMainFocus: vi.fn(),
setComposerText: vi.fn(),
slash: { adapter: null, loading: false }
})
)
act(() => result.current.refreshTrigger())
return result
}
/** The composer's paste handler, minus the clipboard plumbing. */
function paste(editor: HTMLDivElement, text: string) {
insertComposerContentsAtCaret(editor, pathifyRefs(linkifyUrls(text)), openDirectiveScope(editor))
}
describe('directive scope is a browse mode, not text to maintain', () => {
it('Tab-descend carries the scope down instead of dropping to a bare path', () => {
const editor = typed('@folder:apps/deskt')
const result = withTrigger(editor, '@folder:apps/deskt')
expect(result.current.trigger).toMatchObject({ kind: '@', scope: 'folder', value: 'apps/deskt' })
act(() => result.current.replaceTriggerWithChip(folderItem('apps/desktop'), { descend: true }))
expect(composerPlainText(editor)).toBe('@folder:apps/desktop/')
})
it('Backspace climbs the path, then drops the whole scope', () => {
const editor = typed('@folder:apps/desktop/')
const result = withTrigger(editor, '@folder:apps/desktop/')
act(() => result.current.ascendTriggerPath())
expect(composerPlainText(editor)).toBe('@folder:apps/')
act(() => result.current.refreshTrigger())
act(() => result.current.ascendTriggerPath())
expect(composerPlainText(editor)).toBe('@folder:')
// The scope is one unit: Backspace drops it whole rather than nibbling
// back through `:`, `r`, `e`, `d`, `l`, `o`, `f`.
act(() => result.current.refreshTrigger())
act(() => result.current.ascendTriggerPath())
expect(composerPlainText(editor)).toBe('@')
})
it('leaves Backspace alone when there is no scope and no path', () => {
const editor = typed('@apps')
const result = withTrigger(editor, '@apps')
let handled = true
act(() => {
handled = result.current.ascendTriggerPath()
})
expect(handled).toBe(false)
})
it('a pick mid-message keeps the trailing prose and consumes the whole token', () => {
const editor = typed('@folder:apps/deskt and some trailing words', 24)
const result = withTrigger(editor, '@folder:apps/deskt and some trailing words')
act(() => result.current.replaceTriggerWithChip(folderItem('apps/desktop')))
expect(composerPlainText(editor)).toBe('@folder:`apps/desktop/` and some trailing words')
expect(editor.querySelector('[data-ref-kind="folder"]')).not.toBeNull()
})
it('pasting into an open @url: scope consumes it instead of stacking', () => {
const editor = typed('refer to @url:')
paste(editor, 'https://github.com/NousResearch/hermes-agent/pull/74533')
expect(composerPlainText(editor)).toBe('refer to @url:`https://github.com/NousResearch/hermes-agent/pull/74533`')
expect(editor.textContent).not.toContain('@url:@url:')
})
it('a normal paste with no open scope is untouched', () => {
const editor = typed('look at ')
paste(editor, 'https://example.com/x')
expect(composerPlainText(editor)).toBe('look at @url:`https://example.com/x`')
})
it('scope parsing leaves an unscoped @ query alone', () => {
expect(detectTrigger('@apps/desk')).toMatchObject({ kind: '@', value: 'apps/desk' })
expect(detectTrigger('@apps/desk')?.scope).toBeUndefined()
})
it('openDirectiveScope only fires on an EMPTY scope', () => {
// The count is what a paste consumes: `@url:` is 5 characters of syntax
// the user never typed and shouldn't be left holding.
expect(openDirectiveScope(typed('@url:'))).toBe(5)
expect(openDirectiveScope(typed('@url:https://x.com'))).toBe(0)
expect(openDirectiveScope(typed('plain text'))).toBe(0)
})
it('chips stay atomic to scope detection', () => {
const editor = typed('@folder:apps/desktop/')
const result = withTrigger(editor, '@folder:apps/desktop/')
act(() => result.current.replaceTriggerWithChip(folderItem('apps/desktop')))
// A committed chip is one object-replacement char, so a fresh `@` typed
// after it opens an unscoped browse rather than inheriting the old scope.
expect(detectTrigger(`${textBeforeCaret(editor)}@`)?.scope).toBeUndefined()
})
})
@@ -0,0 +1,2 @@
export const COMPOSER_DROP_FADE_CLASS = 'transition-opacity duration-150 ease-out'
export const COMPOSER_DROP_ACTIVE_CLASS = 'opacity-60'
@@ -0,0 +1,270 @@
import { describe, expect, it } from 'vitest'
import {
beginComposerComposition,
composerPlainText,
deleteChipBeforeCaret,
normalizeComposerEditorDom,
renderComposerContents,
RICH_INPUT_SLOT
} from './rich-editor'
function editor(): HTMLDivElement {
const el = document.createElement('div')
el.dataset.slot = RICH_INPUT_SLOT
el.contentEditable = 'true'
document.body.append(el)
return el
}
/** Whatever emptied it — Delete, cut, Chromium's own selection-delete — the
* normalizer lands on the same DOM. */
function emptied(): HTMLDivElement {
const el = editor()
el.append(document.createTextNode('hello'))
el.replaceChildren()
normalizeComposerEditorDom(el)
return el
}
describe('an emptied composer reads as empty', () => {
it('keeps the placeholder <br> so the contenteditable holds its height', () => {
// The scaffolding is deliberate: a childless contenteditable collapses to a
// sliver in Chromium. It just must not read as content.
expect(emptied().innerHTML).toBe('<br>')
})
it('reads that editor as empty, not as a newline', () => {
expect(composerPlainText(emptied())).toBe('')
})
it('reads a truly childless editor as empty', () => {
expect(composerPlainText(editor())).toBe('')
})
it('still reads a real Shift+Enter line break as a newline', () => {
const el = editor()
el.append(document.createTextNode('one'), document.createElement('br'), document.createTextNode('two'))
expect(composerPlainText(el)).toBe('one\ntwo')
})
it('still reads a trailing break after text as a newline', () => {
const el = editor()
el.append(document.createTextNode('one'), document.createElement('br'))
expect(composerPlainText(el)).toBe('one\n')
})
it('only treats the EDITOR\u2019s lone <br> as scaffolding, not a nested one', () => {
// A lone <br> inside some other element is a real line break; the exemption
// is scoped to the editor root by its slot marker. (The block wrapper adds
// its own trailing newline — unchanged behavior, asserted so the exemption
// can't quietly widen to nested nodes.)
const el = editor()
const inner = document.createElement('div')
inner.append(document.createElement('br'))
el.append(document.createTextNode('one'), inner)
expect(composerPlainText(el)).toBe('one\n\n')
})
})
/** The rule the stylesheet paints the placeholder with. `:empty` alone goes
* false the instant the scaffolding <br> lands. */
const PLACEHOLDER_SHOWS = ':is(:empty, [data-empty])'
describe('an emptied composer shows its placeholder again', () => {
it('advertises emptiness once the scaffolding break is in place', () => {
expect(emptied().matches(PLACEHOLDER_SHOWS)).toBe(true)
})
it('advertises emptiness for a truly childless editor', () => {
expect(editor().matches(PLACEHOLDER_SHOWS)).toBe(true)
})
it('stops advertising it once something is typed', () => {
const el = emptied()
el.replaceChildren(document.createTextNode('hi'))
normalizeComposerEditorDom(el)
expect(el.matches(PLACEHOLDER_SHOWS)).toBe(false)
})
// A text node is invisible to selectors, so `one<br>` and `<br>` are the same
// shape to any pure-CSS rule (`:has(> br:only-child)` matches both and paints
// the placeholder straight over the user's text). The DOM writer has to say.
it('does not advertise emptiness for a trailing break after text', () => {
const el = editor()
el.append(document.createTextNode('one'), document.createElement('br'))
normalizeComposerEditorDom(el)
expect(el.matches(PLACEHOLDER_SHOWS)).toBe(false)
})
it('does not advertise emptiness for a Shift+Enter break between text', () => {
const el = editor()
el.append(document.createTextNode('one'), document.createElement('br'), document.createTextNode('two'))
normalizeComposerEditorDom(el)
expect(el.matches(PLACEHOLDER_SHOWS)).toBe(false)
})
// Repainting from text (restored draft, undo, completion rebuild) is the
// other writer that reshapes the editor root — it must not strand the marker.
it('drops the marker when a draft is painted back in', () => {
const el = emptied()
renderComposerContents(el, 'restored draft')
expect(el.matches(PLACEHOLDER_SHOWS)).toBe(false)
})
it('re-advertises emptiness when a draft is painted back out', () => {
const el = editor()
renderComposerContents(el, 'temporary')
renderComposerContents(el, '')
expect(el.matches(PLACEHOLDER_SHOWS)).toBe(true)
})
// Chromium leaves zero-length text nodes behind whenever an edit lands next
// to a contenteditable=false chip. They render as nothing, so an editor
// holding only those is empty to the user — counting them as contents left
// the placeholder hidden under a composer that looked blank.
it('advertises emptiness for an editor holding only zero-length text nodes', () => {
const el = editor()
el.append(document.createTextNode(''), document.createTextNode(''))
normalizeComposerEditorDom(el)
expect(el.matches(PLACEHOLDER_SHOWS)).toBe(true)
})
it('does not advertise emptiness while real text sits beside that litter', () => {
const el = editor()
el.append(document.createTextNode(''), document.createTextNode('one'), document.createTextNode(''))
normalizeComposerEditorDom(el)
expect(el.matches(PLACEHOLDER_SHOWS)).toBe(false)
})
// Input events are skipped for the duration of an IME composition, so nothing
// else clears the marker until it ends — the hint would sit behind the
// hiragana the user is composing (#75960).
it('hides the placeholder before IME preedit text starts', () => {
const el = emptied()
beginComposerComposition(el)
expect(el.matches(PLACEHOLDER_SHOWS)).toBe(false)
})
it('brings the placeholder back when composition ends with nothing committed', () => {
const el = emptied()
beginComposerComposition(el)
normalizeComposerEditorDom(el)
expect(el.matches(PLACEHOLDER_SHOWS)).toBe(true)
})
})
/** A directive chip, as `refChipElement` builds it. */
function chip(): HTMLSpanElement {
const el = document.createElement('span')
el.contentEditable = 'false'
el.dataset.refText = '@folder:`apps/desktop/`'
el.append(document.createTextNode('apps/desktop/'))
return el
}
function caretAt(node: Node, offset: number) {
const range = document.createRange()
range.setStart(node, offset)
range.collapse(true)
const selection = window.getSelection()
selection?.removeAllRanges()
selection?.addRange(range)
}
/** Committing a completion empties the typed token's text node instead of
* removing it, and `Range.insertNode` splits the line around the caret — so a
* freshly-chipped directive sits between zero-length text nodes. Backspace has
* to see past them or the chip can't be deleted at all. */
describe('backspace deletes a chip surrounded by Chromium litter', () => {
it('deletes the chip when the caret sits in a zero-length text node after it', () => {
const el = editor()
el.append(document.createTextNode(''), chip(), document.createTextNode(''))
caretAt(el.childNodes[2] as Node, 0)
expect(deleteChipBeforeCaret(el)).toBe(true)
expect(el.querySelector('[data-ref-text]')).toBeNull()
})
it('deletes the chip when the caret is past a zero-length text node at editor level', () => {
const el = editor()
el.append(chip(), document.createTextNode(''))
caretAt(el, 2)
expect(deleteChipBeforeCaret(el)).toBe(true)
expect(el.querySelector('[data-ref-text]')).toBeNull()
})
it('still swallows the auto-inserted trailing space through that litter', () => {
const el = editor()
el.append(chip(), document.createTextNode(''), document.createTextNode(' '))
caretAt(el, 2)
expect(deleteChipBeforeCaret(el)).toBe(true)
expect(composerPlainText(el)).toBe('')
})
it('keeps real following text when it deletes the chip', () => {
const el = editor()
el.append(chip(), document.createTextNode(''), document.createTextNode(' and this'))
caretAt(el, 2)
expect(deleteChipBeforeCaret(el)).toBe(true)
expect(composerPlainText(el)).toBe('and this')
})
it('leaves plain text to the native backspace', () => {
const el = editor()
el.append(document.createTextNode('hello'))
caretAt(el.firstChild as Node, 5)
expect(deleteChipBeforeCaret(el)).toBe(false)
})
it('sweeps the litter out of the editor when it normalizes', () => {
const el = editor()
el.append(document.createTextNode(''), chip(), document.createTextNode(''))
normalizeComposerEditorDom(el)
expect(Array.from(el.childNodes).map(node => node.nodeName)).toEqual(['SPAN'])
})
})
@@ -0,0 +1,129 @@
import { act, cleanup, fireEvent, render } from '@testing-library/react'
import { useRef } from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest'
afterEach(cleanup)
// Faithful mirror of index.tsx's IME wiring: the composition guard at the top
// of handleEditorKeyDown (self-heal + swallow), the compositionstart/end
// handlers, and the blur reset.
//
// Regression repro for #44135: compositionend can be missed (focus jumps,
// input-source switches, programmatic DOM swaps mid-preedit), leaving
// composingRef wedged true. Before the fix, a wedged flag silently swallowed
// every Enter — and, via the form onSubmit guard, the Send button — until the
// composer remounted, which read as "Enter has no effect, no error, nothing
// reaches the gateway". The fix trusts Chromium's per-keydown isComposing flag
// to clear a stale ref, and clears it on blur (a composition never survives
// focus loss).
function Harness({ onSubmit, wedgeComposing }: { onSubmit: (text: string) => void; wedgeComposing?: boolean }) {
const editorRef = useRef<HTMLDivElement>(null)
const composingRef = useRef(Boolean(wedgeComposing))
const submitDraft = () => {
onSubmit(editorRef.current?.textContent ?? '')
}
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (composingRef.current && !event.nativeEvent.isComposing) {
composingRef.current = false
}
if (composingRef.current || event.nativeEvent.isComposing) {
return
}
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault()
submitDraft()
}
}
return (
<div>
<div
contentEditable
data-testid="editor"
onBlur={() => {
composingRef.current = false
}}
onCompositionEnd={() => {
composingRef.current = false
}}
onCompositionStart={() => {
composingRef.current = true
}}
onKeyDown={handleKeyDown}
ref={editorRef}
suppressContentEditableWarning
/>
<button
data-testid="send"
onClick={() => {
// Mirrors the form onSubmit guard in index.tsx.
if (composingRef.current) {
return
}
submitDraft()
}}
type="button"
/>
</div>
)
}
describe('composer Enter — stale IME composition flag recovery (#44135)', () => {
it('sends on Enter despite a wedged composing flag when the native event says not composing', async () => {
const onSubmit = vi.fn()
const { getByTestId } = render(<Harness onSubmit={onSubmit} wedgeComposing />)
const editor = getByTestId('editor')
await act(async () => {
editor.textContent = 'hello after wedge'
fireEvent.keyDown(editor, { key: 'Enter', isComposing: false })
})
expect(onSubmit).toHaveBeenCalledWith('hello after wedge')
})
it('still swallows Enter during a genuine composition (isComposing keydown)', async () => {
const onSubmit = vi.fn()
const { getByTestId } = render(<Harness onSubmit={onSubmit} />)
const editor = getByTestId('editor')
await act(async () => {
fireEvent.compositionStart(editor)
editor.textContent = '你好'
// The Enter that confirms the preedit: Chromium stamps isComposing=true.
fireEvent.keyDown(editor, { key: 'Enter', isComposing: true })
})
expect(onSubmit).not.toHaveBeenCalled()
// After compositionend, the next Enter sends normally.
await act(async () => {
fireEvent.compositionEnd(editor)
fireEvent.keyDown(editor, { key: 'Enter', isComposing: false })
})
expect(onSubmit).toHaveBeenCalledWith('你好')
})
it('unblocks the Send button after blur even when compositionend was missed', async () => {
const onSubmit = vi.fn()
const { getByTestId } = render(<Harness onSubmit={onSubmit} />)
const editor = getByTestId('editor')
await act(async () => {
fireEvent.compositionStart(editor)
editor.textContent = '发送'
// compositionend never fires (the wedge) — the user mouses to Send,
// blurring the editor.
fireEvent.blur(editor)
fireEvent.click(getByTestId('send'))
})
expect(onSubmit).toHaveBeenCalledWith('发送')
})
})
@@ -0,0 +1,275 @@
import { act, cleanup, fireEvent, render } from '@testing-library/react'
import { useRef, useState } from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest'
// No global setupFiles registers auto-cleanup, so unmount between tests —
// otherwise a second render() leaks the first editor and getByTestId('editor')
// matches multiple nodes.
afterEach(cleanup)
// Faithful mirror of index.tsx's Enter wiring (handleEditorKeyDown's Enter
// branch + submitDraft), driven through REAL DOM keydown events on a
// contentEditable.
//
// Regression repro for #39630: pressing Enter right after typing (fast typing /
// IME) did nothing. The composer state (`draft` from useAuiState) and its
// derived `hasComposerPayload` lag the DOM by a render, so the keydown handler
// read empty state and either dropped the message, drained a queued prompt
// instead of sending, or (while busy) refused to queue. The fix reads the live
// editor text — `hasLivePayload` in the handler and a DOM re-sync at the top of
// submitDraft — so the just-typed text always wins.
//
// We model the race deterministically the way the IME repro does: mutate the
// editor's textContent WITHOUT firing an input event, so the React `draft`
// state stays stale while the DOM already holds the text.
function Harness({
busy = false,
disabled = false,
queued = [],
onSubmit,
onQueue,
onCancel,
onDrain,
onSendNow
}: {
busy?: boolean
disabled?: boolean
queued?: readonly string[]
onSubmit: (text: string) => void
onQueue: (text: string) => void
onCancel: () => void
onDrain: () => void
onSendNow?: (id: string) => void
}) {
const editorRef = useRef<HTMLDivElement>(null)
const draftRef = useRef('')
// Mirrors `useAuiState(s => s.composer.text)` — updated only via setText, so
// it lags the DOM until React re-renders (the source of the bug).
const [draft, setDraft] = useState('')
const attachments: unknown[] = []
const composerPlainText = (el: HTMLElement) => el.textContent ?? ''
const setText = (next: string) => {
draftRef.current = next
setDraft(next)
}
const submitDraft = () => {
if (disabled) {
return
}
const editor = editorRef.current
if (editor) {
const domText = composerPlainText(editor)
if (domText !== draftRef.current) {
draftRef.current = domText
setDraft(domText)
}
}
const text = draftRef.current
const payloadPresent = text.trim().length > 0 || attachments.length > 0
if (busy) {
if (payloadPresent) {
onQueue(text)
} else {
onCancel()
}
} else if (!payloadPresent && queued.length > 0) {
onDrain()
} else if (payloadPresent) {
onSubmit(text)
}
}
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault()
const editorText = editorRef.current ? composerPlainText(editorRef.current) : draftRef.current
const hasLivePayload = editorText.trim().length > 0 || attachments.length > 0
if (disabled) {
return
}
if (!busy && !hasLivePayload && queued.length > 0) {
onDrain()
return
}
if (busy && !hasLivePayload) {
const head = queued[0]
if (head) {
onSendNow?.(head)
}
return
}
submitDraft()
}
}
// `draft` is read so the lint/compiler treats the stale-state mirror as live;
// the assertions prove the handler never relies on it.
void draft
return (
<div
contentEditable
data-testid="editor"
onInput={event => setText(composerPlainText(event.currentTarget))}
onKeyDown={handleKeyDown}
ref={editorRef}
suppressContentEditableWarning
/>
)
}
describe('composer Enter submit — live DOM vs stale composer state (#39630)', () => {
it('sends the just-typed text on Enter even when composer state has not synced', async () => {
const onSubmit = vi.fn()
const { getByTestId } = render(
<Harness onCancel={vi.fn()} onDrain={vi.fn()} onQueue={vi.fn()} onSubmit={onSubmit} />
)
const editor = getByTestId('editor')
// Fast typing: the DOM has the text but NO input event fired, so `draft`
// state is still empty (the exact stale-state race).
await act(async () => {
editor.textContent = 'hello world'
fireEvent.keyDown(editor, { key: 'Enter' })
})
expect(onSubmit).toHaveBeenCalledWith('hello world')
})
it('queues a fast-typed message while busy instead of draining the queue or cancelling', async () => {
const onQueue = vi.fn()
const onDrain = vi.fn()
const onCancel = vi.fn()
const { getByTestId } = render(
<Harness busy onCancel={onCancel} onDrain={onDrain} onQueue={onQueue} onSubmit={vi.fn()} queued={['queued-1']} />
)
const editor = getByTestId('editor')
await act(async () => {
editor.textContent = 'urgent follow-up'
fireEvent.keyDown(editor, { key: 'Enter' })
})
expect(onQueue).toHaveBeenCalledWith('urgent follow-up')
expect(onDrain).not.toHaveBeenCalled()
expect(onCancel).not.toHaveBeenCalled()
})
it('treats an empty Enter while busy with nothing queued as a no-op (never an accidental Stop)', async () => {
const onCancel = vi.fn()
const onSubmit = vi.fn()
const onQueue = vi.fn()
const onSendNow = vi.fn()
const { getByTestId } = render(
<Harness busy onCancel={onCancel} onDrain={vi.fn()} onQueue={onQueue} onSendNow={onSendNow} onSubmit={onSubmit} />
)
const editor = getByTestId('editor')
await act(async () => {
editor.textContent = ''
fireEvent.keyDown(editor, { key: 'Enter' })
})
expect(onCancel).not.toHaveBeenCalled()
expect(onSubmit).not.toHaveBeenCalled()
expect(onQueue).not.toHaveBeenCalled()
expect(onSendNow).not.toHaveBeenCalled()
})
it('double-send: an empty Enter while busy with a queued turn sends that turn now', async () => {
const onCancel = vi.fn()
const onSendNow = vi.fn()
const { getByTestId } = render(
<Harness
busy
onCancel={onCancel}
onDrain={vi.fn()}
onQueue={vi.fn()}
onSendNow={onSendNow}
onSubmit={vi.fn()}
queued={['queued-1', 'queued-2']}
/>
)
const editor = getByTestId('editor')
await act(async () => {
editor.textContent = ''
fireEvent.keyDown(editor, { key: 'Enter' })
})
// Head of the queue, and NOT a bare cancel — send-now promotes + interrupts.
expect(onSendNow).toHaveBeenCalledWith('queued-1')
expect(onCancel).not.toHaveBeenCalled()
})
it('drains the next queued prompt on Enter when idle with a truly empty editor', async () => {
const onDrain = vi.fn()
const onSubmit = vi.fn()
const { getByTestId } = render(
<Harness onCancel={vi.fn()} onDrain={onDrain} onQueue={vi.fn()} onSubmit={onSubmit} queued={['queued-1']} />
)
const editor = getByTestId('editor')
await act(async () => {
editor.textContent = ''
fireEvent.keyDown(editor, { key: 'Enter' })
})
expect(onDrain).toHaveBeenCalledTimes(1)
expect(onSubmit).not.toHaveBeenCalled()
})
it('keeps reconnect drafts editable but blocks Enter submit until the gateway returns', async () => {
const onSubmit = vi.fn()
const onDrain = vi.fn()
const { getByTestId } = render(
<Harness
disabled
onCancel={vi.fn()}
onDrain={onDrain}
onQueue={vi.fn()}
onSubmit={onSubmit}
queued={['queued-1']}
/>
)
const editor = getByTestId('editor')
await act(async () => {
editor.textContent = 'draft while reconnecting'
fireEvent.input(editor)
fireEvent.keyDown(editor, { key: 'Enter' })
})
expect(editor.textContent).toBe('draft while reconnecting')
expect(onDrain).not.toHaveBeenCalled()
expect(onSubmit).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,80 @@
import { afterEach, describe, expect, it } from 'vitest'
import { $workspaceIsPage } from '@/app/routes'
import { closeSwitcher } from '@/store/session-switcher'
import { onComposerFocusRequest } from './focus'
import { handleComposerFocusChord } from './focus-chord'
// jsdom is not macOS, so the chord is Ctrl+L. isComposerChord reads
// ctrlKey on other systems and metaKey on macOS.
function chordEvent(init: KeyboardEventInit = {}): KeyboardEvent {
return new KeyboardEvent('keydown', { bubbles: true, cancelable: true, ctrlKey: true, key: 'l', ...init })
}
/** The bus defers dispatch by one macrotask. This helper flushes it. */
const flushBus = () => new Promise(resolve => setTimeout(resolve, 1))
async function focusRequests(event: KeyboardEvent): Promise<string[]> {
const targets: string[] = []
const off = onComposerFocusRequest(({ target }) => targets.push(target))
handleComposerFocusChord(event)
await flushBus()
off()
return targets
}
afterEach(() => {
$workspaceIsPage.set(false)
closeSwitcher()
document.body.replaceChildren()
})
describe('handleComposerFocusChord', () => {
it('focuses the active composer on the bare chord and swallows the key', async () => {
const event = chordEvent()
expect(await focusRequests(event)).toEqual(['main'])
expect(event.defaultPrevented).toBe(true)
})
it('ignores non-chord keys (plain letter, shifted chord)', async () => {
expect(await focusRequests(chordEvent({ ctrlKey: false }))).toEqual([])
// Shift+chord is the default for view.showBrowser, not for this handler.
expect(await focusRequests(chordEvent({ shiftKey: true }))).toEqual([])
})
it('yields to a press someone else already claimed', async () => {
const event = chordEvent()
event.preventDefault()
expect(await focusRequests(event)).toEqual([])
})
it('leaves the key with a focused terminal: no selection means clear-screen', async () => {
const term = document.createElement('div')
term.setAttribute('data-terminal', '')
const inner = document.createElement('textarea')
term.append(inner)
document.body.append(term)
inner.focus()
const event = chordEvent()
expect(await focusRequests(event)).toEqual([])
expect(event.defaultPrevented).toBe(false)
})
it('stands down behind blocking surfaces (dialog, full page)', async () => {
const dialog = document.createElement('div')
dialog.setAttribute('role', 'dialog')
document.body.append(dialog)
expect(await focusRequests(chordEvent())).toEqual([])
document.body.replaceChildren()
$workspaceIsPage.set(true)
expect(await focusRequests(chordEvent())).toEqual([])
})
})
@@ -0,0 +1,38 @@
/**
* ⌘/Ctrl+L moves focus to the composer from anywhere. This matches the
* address-bar muscle memory of a browser. The other handlers of the chord
* keep priority:
*
* - A terminal or preview selection sends itself to the composer through
* its own capture-phase listener. That listener stops propagation, so
* this handler never sees the press. This handler registers on the
* BUBBLE phase for that reason.
* - A user-rebound action on the same chord dispatches in the capture
* listener of use-keybinds and marks the event handled. This handler
* yields on `defaultPrevented`.
* - A focused terminal with no selection keeps the chord as clear-screen.
* `composerFocusBlockedBySurface()` owns that gate. The same gate covers
* type-to-focus and paste-to-focus, dialogs, full workspace pages, and
* the session switcher.
*/
import { isComposerChord } from '@/lib/keybinds/chords'
import { composerFocusBlockedBySurface } from '@/lib/keybinds/composer-focus-keys'
import { requestComposerFocus } from './focus'
/** The window-level keydown fallback. use-keybinds registers it beside the
* paste listener. When this handler claims the press, it prevents the
* default action. */
export function handleComposerFocusChord(event: KeyboardEvent): void {
if (event.defaultPrevented || !isComposerChord(event)) {
return
}
if (composerFocusBlockedBySurface()) {
return
}
event.preventDefault()
requestComposerFocus('active')
}
@@ -0,0 +1,319 @@
import { afterEach, describe, expect, it } from 'vitest'
import { $hoveredTreeGroup } from '@/components/pane-shell/tree/store'
import {
blurComposerInput,
focusComposerInput,
getActiveComposer,
markActiveComposer,
onComposerFocusRequest,
onComposerModelMenuRequest,
releaseActiveComposer,
requestComposerFocus,
requestModelMenuToggle
} from './focus'
import { RICH_INPUT_SLOT } from './rich-editor'
/**
* Inactive tabs keep their composer mounted, so an unscoped lookup can blur a
* background input and leave the one the user is typing in focused.
*/
/** A composer input inside its own pane layer, hidden or not. */
function mountInput(hidden = false) {
const layer = document.createElement('div')
const input = document.createElement('div')
input.dataset.slot = RICH_INPUT_SLOT
input.tabIndex = 0
layer.toggleAttribute('data-pane-hidden', hidden)
layer.append(input)
document.body.append(layer)
return input
}
/** A chat surface stamp — the same `data-composer-target` ChatView hangs. */
function mountSurface(target: string, hidden = false) {
const layer = document.createElement('div')
layer.toggleAttribute('data-pane-hidden', hidden)
const surface = document.createElement('div')
surface.dataset.composerTarget = target
layer.append(surface)
document.body.append(layer)
return surface
}
afterEach(() => {
document.body.innerHTML = ''
// `activeTarget` is module-level — a case that leaves a stale claim behind
// would otherwise decide the next one.
markActiveComposer('main')
$hoveredTreeGroup.set(null)
})
describe('focusComposerInput', () => {
it('does not steal the caret from another live composer', () => {
const foreground = mountInput()
const background = mountInput()
foreground.focus()
focusComposerInput(background)
expect(document.activeElement).toBe(foreground)
})
it('still focuses when the caret is not already in a composer', () => {
const input = mountInput()
const outside = document.createElement('button')
document.body.append(outside)
outside.focus()
focusComposerInput(input)
expect(document.activeElement).toBe(input)
})
it('takes the caret from a hidden keep-alive composer on tab switch', () => {
const hidden = mountInput(true)
const visible = mountInput()
hidden.focus()
focusComposerInput(visible)
expect(document.activeElement).toBe(visible)
})
})
describe('blurComposerInput', () => {
it('blurs the foreground composer while a hidden tab matches first', () => {
const background = mountInput(true)
const foreground = mountInput()
foreground.focus()
blurComposerInput()
expect(document.activeElement).not.toBe(foreground)
expect(document.activeElement).not.toBe(background)
})
it('leaves focus alone when the composer does not hold it', () => {
const outside = document.createElement('button')
document.body.append(outside)
mountInput()
outside.focus()
blurComposerInput()
expect(document.activeElement).toBe(outside)
})
})
/**
* `markActiveComposer` has four call sites and, unguarded, no counterpart: an
* unmounting or keep-alive-buried composer left `activeTarget` pointing at
* itself, so every `'active'`-routed request was delivered to a target with no
* on-screen subscriber. Type-to-focus preventDefaults the keystroke BEFORE the
* request, so a dead target swallows the character and focuses nothing.
*/
describe('releaseActiveComposer', () => {
it('falls back to the main composer when the claimant releases', () => {
const root = document.createElement('div')
root.dataset.slot = 'aui_edit-composer-root'
document.body.append(root)
markActiveComposer('edit')
expect(getActiveComposer()).toBe('edit')
root.remove()
releaseActiveComposer('edit')
expect(getActiveComposer()).toBe('main')
})
it('leaves the key with the live claimant when a stale composer releases late', () => {
markActiveComposer('edit')
markActiveComposer('tile:abc')
releaseActiveComposer('edit')
expect(getActiveComposer()).toBe('tile:abc')
})
it('prefers the visible chat surface over a hard main default', () => {
const root = document.createElement('div')
root.dataset.slot = 'aui_edit-composer-root'
document.body.append(root)
mountSurface('tile:visible')
markActiveComposer('edit')
root.remove()
releaseActiveComposer('edit')
expect(getActiveComposer()).toBe('tile:visible')
})
it('routes an active-target request to the main composer once the edit composer closes', async () => {
// Mirrors the per-composer filter in use-composer-draft / user-edit-composer:
// a composer ignores any request not addressed to its own target.
const mainComposerSaw: string[] = []
const off = onComposerFocusRequest(({ target }) => {
if (target === 'main') {
mainComposerSaw.push(target)
}
})
const root = document.createElement('div')
root.dataset.slot = 'aui_edit-composer-root'
document.body.append(root)
markActiveComposer('edit')
root.remove()
releaseActiveComposer('edit')
requestComposerFocus('active')
// `dispatch` defers to a macrotask so click/keydown handlers settle first.
await new Promise(resolve => window.setTimeout(resolve, 0))
off()
expect(mainComposerSaw).toEqual(['main'])
})
})
describe('resolveActive / keep-alive tab heal', () => {
it('heals type-to-focus onto the visible main tab when a tile is buried', async () => {
// Repro for the reported main-tab miss: user typed in a session tile, then
// clicked the main/workspace tab without focusing its input. The tile stays
// mounted under data-pane-hidden, so activeTarget still reads tile:… and
// every type-to-focus request is dropped by the visible main composer.
mountSurface('tile:buried', true)
mountSurface('main')
markActiveComposer('tile:buried')
expect(getActiveComposer()).toBe('main')
const mainSaw: string[] = []
const tileSaw: string[] = []
const off = onComposerFocusRequest(({ target }) => {
if (target === 'main') {
mainSaw.push(target)
}
if (target === 'tile:buried') {
tileSaw.push(target)
}
})
requestComposerFocus('active', { typeChar: 'h' })
await new Promise(resolve => window.setTimeout(resolve, 0))
off()
expect(mainSaw).toEqual(['main'])
expect(tileSaw).toEqual([])
// Cache stays honest so dict/insert/Esc path all agree thereafter.
expect(getActiveComposer()).toBe('main')
})
it('keeps a live tile claim while that tile is the visible surface', () => {
mountSurface('main', true)
mountSurface('tile:front')
markActiveComposer('tile:front')
expect(getActiveComposer()).toBe('tile:front')
})
it('heals an edit claim once the edit root is gone (no release site needed)', async () => {
mountSurface('main')
markActiveComposer('edit')
// No edit root in the document → claim is dead. getActiveComposer heals.
expect(getActiveComposer()).toBe('main')
const mainSaw: string[] = []
const off = onComposerFocusRequest(({ target }) => {
if (target === 'main') {
mainSaw.push(target)
}
})
requestComposerFocus('active', { typeChar: 'a' })
await new Promise(resolve => window.setTimeout(resolve, 0))
off()
expect(mainSaw).toEqual(['main'])
})
it('holds an edit claim while the edit composer root is mounted', () => {
const root = document.createElement('div')
root.dataset.slot = 'aui_edit-composer-root'
document.body.append(root)
mountSurface('main')
markActiveComposer('edit')
expect(getActiveComposer()).toBe('edit')
})
})
/** A chat surface inside a layout zone, mirroring ChatView-in-tree-group. */
function mountZonedSurface(target: string, zone: string, hidden = false) {
const group = document.createElement('div')
group.dataset.treeGroup = zone
const layer = document.createElement('div')
layer.toggleAttribute('data-pane-hidden', hidden)
const surface = document.createElement('div')
surface.dataset.composerTarget = target
layer.append(surface)
group.append(layer)
document.body.append(group)
return surface
}
const collectModelMenuTargets = async (): Promise<string[]> => {
const saw: string[] = []
const off = onComposerModelMenuRequest(target => saw.push(target))
await new Promise(resolve => window.setTimeout(resolve, 0))
off()
return saw
}
describe('requestModelMenuToggle', () => {
it('targets the pane under the pointer over the focused one (#74447 convention)', async () => {
mountZonedSurface('main', 'zone-a')
mountZonedSurface('tile:hovered', 'zone-b')
markActiveComposer('main')
$hoveredTreeGroup.set('zone-b')
expect(requestModelMenuToggle()).toBe(true)
expect(await collectModelMenuTargets()).toEqual(['tile:hovered'])
})
it('falls back to the active composer when the pointer is off every zone', async () => {
mountZonedSurface('main', 'zone-a')
mountZonedSurface('tile:other', 'zone-b')
markActiveComposer('tile:other')
expect(requestModelMenuToggle()).toBe(true)
expect(await collectModelMenuTargets()).toEqual(['tile:other'])
})
it('skips a hidden keep-alive tab in the hovered zone (targets its visible sibling)', async () => {
mountZonedSurface('main', 'zone-a', true)
mountZonedSurface('tile:front', 'zone-a')
markActiveComposer('main')
$hoveredTreeGroup.set('zone-a')
expect(requestModelMenuToggle()).toBe(true)
expect(await collectModelMenuTargets()).toEqual(['tile:front'])
})
it('returns false with no chat surface on screen so the caller can open the dialog', async () => {
// Settings/profiles routes: no [data-composer-target] anywhere.
expect(requestModelMenuToggle()).toBe(false)
expect(await collectModelMenuTargets()).toEqual([])
})
})
+430
View File
@@ -0,0 +1,430 @@
/**
* Composer focus + external-insert bus.
*
* Mutations from outside the composer (sidebar attach, drag drop, terminal
* Cmd+L, preview console, etc.) dispatch through here. Each composer subscribes
* and routes the work back into its own ref/state.
*
* `dispatch` defers to a macrotask so synchronous click/keydown handlers
* (react-arborist row focus, picker `node.select()`) finish first and don't
* steal focus from the composer effect.
*/
import { isElementInHiddenPane, queryAllVisible, queryVisible } from '@/components/pane-shell/pane-visibility'
import { $hoveredTreeGroup } from '@/components/pane-shell/tree/store'
import type { InlineRefInput } from './inline-refs'
import { RICH_INPUT_SLOT } from './rich-editor'
/** Composer routing key. The main chat is `'main'`, the edit composer
* `'edit'`; scoped composers (session tiles) use `'tile:<id>'`. */
export type ComposerTarget = 'edit' | 'main' | (string & {})
export type ComposerInsertMode = 'block' | 'inline' | 'prefix'
export interface FocusDetail {
target: ComposerTarget
/** Append after focus (type-to-focus / soft `/`). */
typeChar?: string
}
interface InsertDetail {
mode: ComposerInsertMode
target: ComposerTarget
text: string
}
interface InsertRefsDetail {
refs: InlineRefInput[]
target: ComposerTarget
}
interface AttachImagesDetail {
blobs: Blob[]
target: ComposerTarget
}
const FOCUS_EVENT = 'hermes:composer-focus'
const INSERT_EVENT = 'hermes:composer-insert'
const ATTACH_IMAGES_EVENT = 'hermes:composer-attach-images'
const INSERT_REFS_EVENT = 'hermes:composer-insert-refs'
const SUBMIT_EVENT = 'hermes:composer-submit'
const VOICE_TOGGLE_EVENT = 'hermes:composer-voice-toggle'
const MODEL_MENU_EVENT = 'hermes:composer-model-menu'
/** Inline edit composer root — mounted only while a user bubble is being edited. */
const EDIT_COMPOSER_ROOT = '[data-slot="aui_edit-composer-root"]'
/** Attribute-safe selector fragment. jsdom (vitest) does not ship `CSS.escape`. */
const cssEscape = (value: string): string => {
if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function') {
return CSS.escape(value)
}
// Our targets are `'main'` / `'edit'` / `'tile:<id>'` — alphanumerics plus `:`
// and `-`. Escape anything outside that set so a weird id cannot break the
// attribute selector.
return value.replace(/[^a-zA-Z0-9_:-]/g, ch => `\\${ch}`)
}
interface SubmitDetail {
/** Unique mounted composer surface captured at click time. */
surfaceId: string
target: ComposerTarget
text: string
/** `hidden` types the persisted user row so no bubble renders — the
* off-screen path for widget intents. Omit for normal visible sends. */
displayKind?: 'hidden'
}
let activeTarget: ComposerTarget = 'main'
/**
* The chat surface currently on screen (`data-composer-target` hung off each
* ChatView). Inactive tabs stay mounted with `data-pane-hidden`, so this uses
* the same visibility policy as every other document-wide surface lookup.
*/
const visibleChatTarget = (): ComposerTarget | null => {
if (typeof document === 'undefined') {
return null
}
const surface = queryVisible<HTMLElement>('[data-composer-target]')
const target = surface?.dataset.composerTarget
return target ? (target as ComposerTarget) : null
}
/** True when `target` still has a live, on-screen subscriber. */
const targetIsReachable = (target: ComposerTarget): boolean => {
if (typeof document === 'undefined') {
return true
}
// The edit composer is an in-thread overlay, not a chat surface — it never
// stamps `data-composer-target`. While its root is mounted it still owns the
// bus; once it tears down the claim is dead.
if (target === 'edit') {
return Boolean(document.querySelector(EDIT_COMPOSER_ROOT))
}
// Exact match on a VISIBLE surface. Background keep-alive tabs carry the same
// `data-composer-target` but sit under `data-pane-hidden`, so queryVisible
// filters them out.
if (queryVisible(`[data-composer-target="${cssEscape(target)}"]`)) {
return true
}
// A different chat surface is on screen → this claim is buried or gone.
// (A claim with zero stamped surfaces yet — first paint, pure-unit tests —
// keeps the marked key until the DOM contradicts it.)
if (queryVisible('[data-composer-target]')) {
return false
}
return true
}
/**
* The composer `'active'` should route to right now.
*
* The cached claim (`activeTarget`) wins while its surface is still on screen.
* Tab stacks keep inactive panes mounted, so focusing a tile then clicking the
* main tab leaves `activeTarget` pointing at a buried composer — with no
* subscriber on the visible surface, every type-to-focus keystroke is
* preventDefault'd and dropped. Heal to the visible chat surface (or main)
* whenever the claim is off-screen or gone, and keep the cache honest so Esc /
* voice / soft `/` agree with the keyboard path.
*/
const resolveActive = (): ComposerTarget => {
if (targetIsReachable(activeTarget)) {
return activeTarget
}
const visible = visibleChatTarget() ?? 'main'
activeTarget = visible
return visible
}
const resolve = (target: ComposerTarget | 'active') => (target === 'active' ? resolveActive() : target)
const dispatch = <T>(name: string, detail: T) => {
if (typeof window === 'undefined') {
return
}
window.setTimeout(() => window.dispatchEvent(new CustomEvent<T>(name, { detail })), 0)
}
/** Submit is the one bus mutation that must preserve the chat visible at click
* time. Deferring it lets a parent click handler/tab reveal switch the active
* keep-alive pane before subscribers run, so the task is dropped or claimed by
* another composer. Other bus events intentionally defer for focus restoration.
*/
const dispatchNow = <T>(name: string, detail: T) => {
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent<T>(name, { detail }))
}
}
/** Unique identity for the visible composer surface addressed by a submit. */
const getVisibleComposerSurfaceId = (target: ComposerTarget): string | null => {
if (typeof document === 'undefined') {
return null
}
const surface = queryVisible<HTMLElement>(`[data-composer-target="${cssEscape(target)}"]`)
return surface?.dataset.composerSurfaceId || null
}
const composerSurfaceIsVisible = (target: ComposerTarget, surfaceId: string): boolean => {
if (typeof document === 'undefined') {
return false
}
return queryAllVisible<HTMLElement>(`[data-composer-target="${cssEscape(target)}"]`).some(
surface => surface.dataset.composerSurfaceId === surfaceId
)
}
const subscribe = <T>(name: string, handler: (detail: T) => void) => {
if (typeof window === 'undefined') {
return () => undefined
}
const listener = (event: Event) => {
const detail = (event as CustomEvent<T>).detail
if (detail) {
handler(detail)
}
}
window.addEventListener(name, listener)
return () => window.removeEventListener(name, listener)
}
export const markActiveComposer = (target: ComposerTarget) => {
activeTarget = target
}
/** Hand the routing key back when a composer unmounts, so `'active'` can never
* resolve to a composer that no longer has a subscriber — such a request is
* dispatched and then dropped by every mounted composer's target filter, and
* nothing re-marks the active composer on its own.
*
* Guarded on identity: a composer unmounting AFTER another one claimed the key
* (closing a background tile, a deferred edit-close cleanup) must not steal it
* from the live claimant. Falls through to {@link resolveActive} when the
* caller's surface is buried rather than gone, so closing on a tab switch that
* already re-fronted another chat surfaces there immediately. */
export const releaseActiveComposer = (target: ComposerTarget) => {
if (activeTarget !== target) {
return
}
// Prefer the visible chat surface over a hard `'main'` default — releasing a
// closed tile while another tile is fronted should land there, not the
// (possibly buried) workspace tab.
activeTarget = visibleChatTarget() ?? 'main'
}
/** The composer that last held focus — the target `'active'` resolves to.
* Used by broadcast listeners (voice, Esc-to-stop) to act on exactly one.
* Heals a stale claim the same way {@link requestComposerFocus} does, so Esc
* and type-to-focus never disagree after a tab switch left the bus pointing at
* a keep-alive-mounted background composer. */
export const getActiveComposer = (): ComposerTarget => resolveActive()
export const requestComposerFocus = (
target: ComposerTarget | 'active' = 'active',
{ typeChar }: { typeChar?: string } = {}
) => dispatch<FocusDetail>(FOCUS_EVENT, { target: resolve(target), typeChar })
export const requestComposerInsert = (
text: string,
{ mode = 'block', target = 'active' }: { mode?: ComposerInsertMode; target?: ComposerTarget | 'active' } = {}
) => {
const trimmed = text.trim()
if (!trimmed) {
return
}
dispatch<InsertDetail>(INSERT_EVENT, { mode, target: resolve(target), text: trimmed })
}
export const onComposerFocusRequest = (handler: (detail: FocusDetail) => void) =>
subscribe<FocusDetail>(FOCUS_EVENT, handler)
export const onComposerInsertRequest = (handler: (detail: InsertDetail) => void) =>
subscribe<InsertDetail>(INSERT_EVENT, handler)
/** Attach image blobs to a composer's attachment set — the unfocused-paste
* path (paste-to-focus) hands clipboard images over here. The edit composer
* takes no attachments (its own paste path ignores images), so a request
* resolving to `'edit'` is dropped by that surface's target filter. */
export const requestComposerAttachImages = (
blobs: Blob[],
{ target = 'active' }: { target?: ComposerTarget | 'active' } = {}
) => {
if (blobs.length) {
dispatch<AttachImagesDetail>(ATTACH_IMAGES_EVENT, { blobs, target: resolve(target) })
}
}
export const onComposerAttachImagesRequest = (handler: (detail: AttachImagesDetail) => void) =>
subscribe<AttachImagesDetail>(ATTACH_IMAGES_EVENT, handler)
/** Insert typed ref chips (carrying a display label) into a composer — the
* structured cousin of {@link requestComposerInsert}, used for session links. */
export const requestComposerInsertRefs = (
refs: InlineRefInput[],
{ target = 'active' }: { target?: ComposerTarget | 'active' } = {}
) => {
if (refs.length) {
dispatch<InsertRefsDetail>(INSERT_REFS_EVENT, { refs, target: resolve(target) })
}
}
export const onComposerInsertRefsRequest = (handler: (detail: InsertRefsDetail) => void) =>
subscribe<InsertRefsDetail>(INSERT_REFS_EVENT, handler)
/** Submit a prompt through a composer as if the user typed + sent it. Lets
* external panels (e.g. the review pane's "let the agent ship it" button) hand
* the agent a task without the user round-tripping through the input. */
export const requestComposerSubmit = (
text: string,
{
displayKind,
surfaceId: requestedSurfaceId,
target = 'active'
}: { displayKind?: 'hidden'; surfaceId?: null | string; target?: ComposerTarget | 'active' } = {}
): boolean => {
const trimmed = text.trim()
if (!trimmed) {
return false
}
const resolvedTarget = resolve(target)
const surfaceId = requestedSurfaceId === undefined ? getVisibleComposerSurfaceId(resolvedTarget) : requestedSurfaceId
// Fail closed: without an exact visible surface identity, broadcasting a
// submit could make more than one keep-alive/new-chat composer claim it.
if (!surfaceId || (requestedSurfaceId !== undefined && !composerSurfaceIsVisible(resolvedTarget, surfaceId))) {
return false
}
dispatchNow<SubmitDetail>(SUBMIT_EVENT, {
surfaceId,
target: resolvedTarget,
text: trimmed,
...(displayKind ? { displayKind } : {})
})
return true
}
export const onComposerSubmitRequest = (handler: (detail: SubmitDetail) => void) =>
subscribe<SubmitDetail>(SUBMIT_EVENT, handler)
/** Toggle ONE composer's voice conversation — the `composer.voice` hotkey
* (Ctrl+B) reaches the composer that owns voice. Defaults to the active
* composer so N tiles don't all flip together. */
export const requestVoiceToggle = (target: ComposerTarget | 'active' = 'active') =>
dispatch<{ target: ComposerTarget }>(VOICE_TOGGLE_EVENT, { target: resolve(target) })
export const onComposerVoiceToggleRequest = (handler: (target: ComposerTarget) => void) =>
subscribe<{ target: ComposerTarget }>(VOICE_TOGGLE_EVENT, ({ target }) => handler(target))
/** The chat surface inside the zone the pointer is over, if any. Mirrors the
* tab verbs' hover-first targeting (`tabTargetGroupId`, #74447): the model
* hotkey lands in the pane you're pointing at without clicking into it first.
* Hidden keep-alive tabs are skipped like every document-wide lookup. */
const composerTargetInHoveredZone = (): ComposerTarget | null => {
const zone = $hoveredTreeGroup.get()
if (!zone || typeof document === 'undefined') {
return null
}
const surface = queryAllVisible<HTMLElement>('[data-composer-target]').find(
el => el.closest<HTMLElement>('[data-tree-group]')?.dataset.treeGroup === zone
)
return (surface?.dataset.composerTarget as ComposerTarget | undefined) ?? null
}
/** Toggle ONE composer's model menu — the `composer.modelPicker` hotkey.
* Targets the pane under the pointer first (the tab-verb convention), then
* the active composer. Returns false when no chat surface is on screen at
* all (settings, profiles…), so the caller can fall back to the full
* model-picker dialog instead of dispatching into the void. */
export const requestModelMenuToggle = (): boolean => {
if (typeof document !== 'undefined' && !queryVisible('[data-composer-target]')) {
return false
}
dispatch<{ target: ComposerTarget }>(MODEL_MENU_EVENT, {
target: composerTargetInHoveredZone() ?? resolveActive()
})
return true
}
export const onComposerModelMenuRequest = (handler: (target: ComposerTarget) => void) =>
subscribe<{ target: ComposerTarget }>(MODEL_MENU_EVENT, ({ target }) => handler(target))
/**
* Focus a composer input across React commit + browser focus restore.
*
* The triple-call survives:
* - sync: contenteditable already mounted
* - rAF: React just committed a `renderComposerContents` swap
* - 0ms: browser focus reclaim from a click target inside an external panel
*/
export const focusComposerInput = (el: HTMLElement | null) => {
if (!el) {
return
}
// Skip when already focused: focus() runs the full focusing steps (forcing
// layout) even on the active element, and during a session switch the DOM is
// large and dirty — the redundant retries were measurably expensive there.
// Also skip when another VISIBLE composer holds the caret — a keep-alive
// remount must not yank typing. A hidden tab that still has DOM focus must
// not block the pane the user just switched to.
const focus = () => {
if (document.activeElement === el) {
return
}
const active = document.activeElement
if (active instanceof HTMLElement && active.dataset.slot === RICH_INPUT_SLOT && !isElementInHiddenPane(active)) {
return
}
el.focus({ preventScroll: true })
}
focus()
window.requestAnimationFrame(focus)
window.setTimeout(focus, 0)
}
/** Drop focus from the main composer input (status-stack chrome, sidebar, etc.).
* Skips inactive tabs — they stay mounted, so an unscoped lookup can land on a
* background composer and leave the visible one focused. */
export const blurComposerInput = () => {
const el = queryVisible(`[data-slot="${RICH_INPUT_SLOT}"]`)
if (el && document.activeElement === el) {
el.blur()
}
}
@@ -0,0 +1,84 @@
import type { ReactNode } from 'react'
import { KbdCombo } from '@/components/ui/kbd'
import { useI18n } from '@/i18n'
import { COMPLETION_DRAWER_CLASS } from './completion-drawer'
const COMMON_COMMAND_KEYS = ['/help', '/clear', '/resume', '/details', '/copy', '/quit']
/** Stable ids → i18n `hotkeyDescs` keys. Combos resolve mod labels per OS. */
const COMPOSER_HOTKEY_ROWS = [
{ id: 'composer.mention', combos: ['@'] },
{ id: 'composer.slash', combos: ['/'] },
{ id: 'composer.help', combos: ['?'] },
{ id: 'composer.sendNewline', combos: ['enter', 'shift+enter'] },
{ id: 'composer.sendQueued', combos: ['mod+shift+k'] },
{ id: 'keybinds.openPanel', combos: ['mod+/'] },
{ id: 'composer.cancel', combos: ['escape'] },
{ id: 'composer.history', combos: ['up', 'down'] }
] as const
export function HelpHint() {
const { t } = useI18n()
const c = t.composer
return (
<div className={COMPLETION_DRAWER_CLASS} data-slot="composer-completion-drawer" data-state="open" role="dialog">
<Section title={c.commonCommands}>
{COMMON_COMMAND_KEYS.map(key => (
<Row description={c.commandDescs[key] ?? ''} key={key} keyLabel={key} mono />
))}
</Section>
<Section title={c.hotkeys}>
{COMPOSER_HOTKEY_ROWS.map(row => (
<HotkeyRow combos={[...row.combos]} description={c.hotkeyDescs[row.id] ?? ''} key={row.id} />
))}
</Section>
<p className="px-2.5 py-1 text-xs text-muted-foreground/80">
<span className="font-mono text-foreground/80">/help</span> {c.helpFooter}
</p>
</div>
)
}
function Section({ children, title }: { children: ReactNode; title: string }) {
return (
<div className="grid gap-0.5 pt-0.5">
<p className="px-2.5 pb-0.5 pt-1 text-[0.65rem] font-medium uppercase tracking-wide text-muted-foreground/75">
{title}
</p>
{children}
</div>
)
}
function Row({ description, keyLabel, mono = false }: { description: string; keyLabel: string; mono?: boolean }) {
return (
<div className="flex min-w-0 items-baseline gap-2 rounded-md px-2.5 py-1 text-xs">
<span
className={
mono ? 'shrink-0 truncate font-mono font-medium text-foreground/85' : 'shrink-0 truncate text-foreground/85'
}
>
{keyLabel}
</span>
<span className="min-w-0 truncate text-muted-foreground/80">{description}</span>
</div>
)
}
function HotkeyRow({ combos, description }: { combos: string[]; description: string }) {
return (
<div className="flex min-w-0 items-center gap-2 rounded-md px-2.5 py-1 text-xs">
<span className="flex shrink-0 items-center gap-1">
{combos.map(combo => (
<KbdCombo combo={combo} key={combo} size="sm" />
))}
</span>
<span className="min-w-0 truncate text-muted-foreground/80">{description}</span>
</div>
)
}
@@ -0,0 +1,121 @@
/**
* `composer.atCompletions` contributions merge into the `@` popover ahead of
* the gateway's path results (#88060 — Bot Mode agent handles). Covers: rows
* appear, prefix filtering is delegated to the source, contributed rows lead,
* and a throwing source is isolated instead of killing the popover.
*/
import { act, renderHook } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { COMPOSER_AREAS, type ComposerAtCompletionSource } from '@/app/chat/composer/contrib'
import { registry } from '@/contrib/registry'
import { queryClient } from '@/lib/query-client'
import { useAtCompletions } from './use-at-completions'
const disposers: Array<() => void> = []
function addSource(id: string, provide: ComposerAtCompletionSource['provide']) {
disposers.push(
registry.register({
id,
area: COMPOSER_AREAS.atCompletions,
data: { provide } satisfies ComposerAtCompletionSource
})
)
}
function gatewayStub(items = [{ text: '@file:src/research-notes.md', display: 'research-notes.md', meta: 'file' }]) {
return {
request: vi.fn(async () => ({ items }))
}
}
/** Drive the adapter like the popover does: search, let the debounce and
* fetch settle, then read the settled items from a second search call
* (the adapter returns the currently-held rows synchronously). */
async function searchAndRead(
result: { current: { adapter: { search?: (q: string) => readonly { label: string }[] } } },
q: string
): Promise<string[]> {
act(() => {
result.current.adapter.search?.(q)
})
await act(async () => {
await vi.advanceTimersByTimeAsync(300)
})
let rows: readonly { label: string }[] = []
act(() => {
rows = result.current.adapter.search?.(q) || []
})
return rows.map(r => r.label)
}
afterEach(() => {
disposers.splice(0).forEach(d => d())
queryClient.clear()
vi.useRealTimers()
})
describe('contributed @ completion sources', () => {
it('merges contributed rows ahead of gateway path results', async () => {
vi.useFakeTimers()
addSource('bots', q => ('researcher'.startsWith(q) ? [{ insert: '@researcher', meta: 'Bot · Researcher' }] : []))
const { result } = renderHook(() =>
useAtCompletions({ gateway: gatewayStub() as never, sessionId: 's1', cwd: '/repo' })
)
const rows = await searchAndRead(result, 'rese')
expect(rows[0]).toBe('@researcher')
expect(rows.some(l => l.includes('research-notes.md'))).toBe(true)
})
it('deduplicates contributed and core profile handles without collapsing distinct references', async () => {
vi.useFakeTimers()
addSource('bots', q => ('researcher'.startsWith(q) ? [{ insert: '@Researcher', meta: 'Bot · Researcher' }] : []))
const gateway = gatewayStub([
{ text: '@researcher', display: '@researcher', meta: 'Research and intelligence analyst' },
{ text: '@researcher-homelab', display: '@researcher-homelab', meta: 'agent profile' },
{ text: '@file:src/researcher.md', display: 'researcher.md', meta: 'file' }
])
const { result } = renderHook(() => useAtCompletions({ gateway: gateway as never, sessionId: 's1', cwd: '/repo' }))
const rows = await searchAndRead(result, 'rese')
expect(rows.filter(label => label.toLowerCase() === '@researcher')).toEqual(['@Researcher'])
expect(rows).toContain('@researcher-homelab')
expect(rows.some(label => label.includes('researcher.md'))).toBe(true)
})
it('drops rows when the query does not match the source filter', async () => {
vi.useFakeTimers()
addSource('bots', q => ('researcher'.startsWith(q) ? [{ insert: '@researcher', meta: 'Bot' }] : []))
const { result } = renderHook(() =>
useAtCompletions({ gateway: gatewayStub() as never, sessionId: 's1', cwd: '/repo' })
)
const rows = await searchAndRead(result, 'zzz')
expect(rows).not.toContain('@researcher')
})
it('isolates a throwing source instead of killing the popover', async () => {
vi.useFakeTimers()
addSource('broken', () => {
throw new Error('boom')
})
addSource('bots', () => [{ insert: '@researcher' }])
const { result } = renderHook(() =>
useAtCompletions({ gateway: gatewayStub() as never, sessionId: 's1', cwd: '/repo' })
)
const rows = await searchAndRead(result, 'rese')
expect(rows[0]).toBe('@researcher')
expect(rows.length).toBeGreaterThan(1)
})
})
@@ -0,0 +1,107 @@
import { act, renderHook } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { queryClient } from '@/lib/query-client'
import { useAtCompletions } from './use-at-completions'
function gatewayStub(latencyMs = 40) {
const calls: string[] = []
const gateway = {
request: vi.fn(async (_method: string, params: { word: string }) => {
calls.push(params.word)
await new Promise(r => setTimeout(r, latencyMs))
return { items: [{ text: `@folder:${params.word.slice(1)}x/`, display: 'x/', meta: 'dir' }] }
})
}
return { calls, gateway }
}
function setup(latencyMs = 40) {
const { calls, gateway } = gatewayStub(latencyMs)
const { result } = renderHook(() => useAtCompletions({ gateway: gateway as never, sessionId: 's1', cwd: '/repo' }))
return { calls, result }
}
/** Type a burst of keystrokes `gapMs` apart, like a person. */
async function type(
result: { current: { adapter: { search?: (q: string) => unknown } } },
queries: string[],
gapMs: number
) {
for (const q of queries) {
act(() => {
result.current.adapter.search?.(q)
})
await act(async () => {
await vi.advanceTimersByTimeAsync(gapMs)
})
}
}
describe('PERF: @ path completions are cached and skip the debounce', () => {
it('serves a repeated query with no round trip and no spinner', async () => {
vi.useFakeTimers()
queryClient.clear()
const { calls, result } = setup()
// First visit to `apps/` pays the round trip.
await type(result, ['apps/'], 0)
await act(async () => {
await vi.advanceTimersByTimeAsync(200)
})
const afterFirst = calls.length
expect(afterFirst).toBe(1)
// Walk away and come back — Tab in, Backspace out, retype. Every one of
// these used to be a fresh git ls-files + rank on the backend.
await type(result, ['apps/desktop/', 'apps/', 'apps/desktop/', 'apps/'], 0)
await act(async () => {
await vi.advanceTimersByTimeAsync(200)
})
// Two distinct paths, so exactly two round trips total — the repeats are free.
expect(calls.length).toBe(2)
expect(result.current.loading).toBe(false)
vi.useRealTimers()
})
it('a cached query paints without waiting out the debounce', async () => {
vi.useFakeTimers()
queryClient.clear()
const { calls, result } = setup()
await type(result, ['apps/'], 0)
await act(async () => {
await vi.advanceTimersByTimeAsync(200)
})
expect(calls.length).toBe(1)
// Re-ask for the cached query and advance by far less than the 60ms
// debounce. A cached answer resolves in a microtask, so it must paint
// without the timer and without ever flipping the spinner on.
act(() => {
result.current.adapter.search?.('apps/')
})
await act(async () => {
await vi.advanceTimersByTimeAsync(1)
})
expect(result.current.loading).toBe(false)
expect(calls.length).toBe(1)
vi.useRealTimers()
})
})
@@ -0,0 +1,239 @@
import type { Unstable_TriggerAdapter, Unstable_TriggerItem } from '@assistant-ui/core'
import { useCallback } from 'react'
import { refChipLabel } from '@/components/assistant-ui/directive-text'
import { useContributions } from '@/contrib/react/use-contributions'
import type { HermesGateway } from '@/hermes'
import { cachedPathCompletion, hasCachedPathCompletion } from '@/lib/slash-completion-cache'
import { normalize } from '@/lib/text'
import type { ComposerAtCompletionSource } from '../contrib'
import { COMPOSER_AREAS } from '../contrib'
import type { CompletionEntry, CompletionPayload } from './use-live-completion-adapter'
import { useLiveCompletionAdapter } from './use-live-completion-adapter'
const KIND_RE = /^@(file|folder|url|image|tool|git):(.*)$/
const REF_STARTERS = new Set(['file', 'folder', 'url', 'image', 'tool', 'git'])
// These bare tokens are context actions, not profile handles.
const SIMPLE_CONTEXT_REFS = new Set(['@diff', '@staged'])
const STARTER_META: Record<string, string> = {
file: 'Attach a file reference',
folder: 'Attach a folder reference',
url: 'Attach a URL reference',
image: 'Attach an image reference',
tool: 'Attach a tool reference',
git: 'Attach git context'
}
function starterEntries(query: string): CompletionEntry[] {
const q = normalize(query)
const kinds = Array.from(REF_STARTERS)
const filtered = q ? kinds.filter(kind => kind.startsWith(q)) : kinds
return filtered.map(kind => ({
text: `@${kind}:`,
display: `@${kind}:`,
meta: STARTER_META[kind] || ''
}))
}
function mergeCompletionEntries(preferred: CompletionEntry[], fallback: CompletionEntry[]): CompletionEntry[] {
const seenHandles = new Set<string>()
return [...preferred, ...fallback].filter(entry => {
const key = normalize(entry.text)
if (!/^@[^:\s]+$/.test(key) || SIMPLE_CONTEXT_REFS.has(key)) {
return true
}
if (seenHandles.has(key)) {
return false
}
seenHandles.add(key)
return true
})
}
interface AtItemMetadata extends Record<string, string> {
icon: string
display: string
meta: string
/** Raw `text` field from the gateway, e.g. `@file:src/main.tsx` or `@diff`. */
rawText: string
/** Just the value portion (after `@kind:`), or empty for simple refs. */
insertId: string
}
function textValue(value: unknown, fallback = ''): string {
return typeof value === 'string' ? value : fallback
}
/** Parse the gateway's `text` field (`@file:src/foo.ts`, `@diff`, `@folder:`) into popover-ready data. */
function classify(entry: CompletionEntry): {
type: string
insertId: string
display: string
meta: string
} {
const match = KIND_RE.exec(entry.text)
if (match) {
const [, kind, rest] = match
return {
type: kind,
insertId: rest,
// The row shows exactly what picking it produces. Upstream keeps one
// label per item and hands it to the chip verbatim (DirectiveNode's
// `__label = item.label`); our wire format is `@kind:value`, which can't
// carry a label the way their `:type[label]{name=id}` does, so the same
// invariant is held by deriving both ends from refChipLabel. Without
// this the list said `desktop/`, the editor said `apps/desktop/`, and
// the chip said `desktop` — three names for one folder.
display: rest ? refChipLabel(kind, rest) : textValue(entry.display, `@${kind}:`),
meta: textValue(entry.meta)
}
}
return {
type: 'simple',
insertId: entry.text,
display: textValue(entry.display, entry.text),
meta: textValue(entry.meta)
}
}
/** Live `@` completions backed by the gateway's `complete.path` RPC, with
* contributed sources (composer.atCompletions — e.g. Bot Mode agent handles)
* merged ahead of the path results. */
export function useAtCompletions(options: {
gateway: HermesGateway | null
sessionId: string | null
cwd: string | null
}): { adapter: Unstable_TriggerAdapter; loading: boolean } {
const { gateway, sessionId, cwd } = options
const enabled = Boolean(gateway)
const contributed = useContributions(COMPOSER_AREAS.atCompletions)
// Contributed rows for the query, mapped into the gateway entry shape so
// one classify/toItem path renders every row. Provider errors are isolated:
// a throwing source drops ITS rows, never the popover.
const contributedEntries = useCallback(
(query: string): CompletionEntry[] => {
const out: CompletionEntry[] = []
for (const contribution of contributed) {
const source = contribution.data as ComposerAtCompletionSource | undefined
if (!source || typeof source.provide !== 'function') {
continue
}
try {
for (const item of source.provide(query) || []) {
if (!item || typeof item.insert !== 'string' || !item.insert) {
continue
}
out.push({
text: item.insert,
display: item.display || item.insert,
meta: item.meta || '',
icon: item.icon || ''
} as CompletionEntry)
}
} catch {
/* a broken source must not take down @ completions */
}
}
return out
},
[contributed]
)
// Cache key: the completion depends on the query AND the directory it's
// resolved against, so a cwd or session change can't serve another tree's
// listing.
const cacheKey = useCallback((query: string) => `${cwd ?? ''}|${sessionId ?? ''}|${query}`, [cwd, sessionId])
const fetcher = useCallback(
async (query: string): Promise<CompletionPayload> => {
const starters = starterEntries(query)
const extras = contributedEntries(query)
if (!gateway) {
return { items: mergeCompletionEntries(extras, starters), query }
}
const word = REF_STARTERS.has(query) ? `@${query}:` : `@${query}`
const params: Record<string, unknown> = { word }
if (sessionId) {
params.session_id = sessionId
}
if (cwd) {
params.cwd = cwd
}
try {
// De-duplicated the same way `/` completions are. Walking a path is
// inherently repetitive — Tab into a folder, Backspace out, retype a
// segment — and every one of those steps used to be a fresh
// `git ls-files` + rank on the backend (~40ms of the ~50ms round trip
// measured on this repo's 8k files).
const result = await cachedPathCompletion(cacheKey(query), () =>
gateway.request<{ items?: CompletionEntry[] }>('complete.path', params)
)
const items = result.items ?? []
const base = items.length > 0 ? items : starters
return { items: mergeCompletionEntries(extras, base), query }
} catch {
return { items: mergeCompletionEntries(extras, starters), query }
}
},
[cacheKey, contributedEntries, gateway, sessionId, cwd]
)
const toItem = useCallback((entry: CompletionEntry, index: number): Unstable_TriggerItem => {
const classified = classify(entry)
const metadata: AtItemMetadata = {
icon: classified.type,
display: classified.display,
meta: classified.meta,
rawText: entry.text,
insertId: classified.insertId
}
return {
// Unique id keyed on the gateway's full `text` so two entries that share
// a basename (e.g. multiple `index.ts`) don't collide in keyboard nav.
id: `${entry.text}|${index}`,
type: classified.type,
label: classified.display,
...(classified.meta ? { description: classified.meta } : {}),
metadata
}
}, [])
// A query already in cache skips both the debounce and the loading state.
// This is what makes walking a tree feel instant rather than merely fast:
// the 60ms debounce exists to avoid a request per keystroke, and it buys
// nothing when the answer is already in hand.
const isCached = useCallback((query: string) => hasCachedPathCompletion(cacheKey(query)), [cacheKey])
return useLiveCompletionAdapter({ enabled, fetcher, isCached, toItem })
}
/** Re-export `classify` for use by the formatter (insertion side). */
export { classify }
@@ -0,0 +1,132 @@
import { act, cleanup, renderHook, waitFor } from '@testing-library/react'
import { atom } from 'nanostores'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { assistantTextPart, type ChatMessage, chatMessageText } from '@/lib/chat-messages'
import { clearSpokenRepliesForTests, markAssistantIdSpoken, resolveSpokenReply } from '@/lib/spoken-reply'
import { playSpeechText } from '@/lib/voice-playback'
import { $voicePlayback, setVoicePlaybackState } from '@/store/voice-playback'
import { $autoSpeakReplies } from '@/store/voice-prefs'
import { ComposerScopeProvider, MAIN_COMPOSER_SCOPE } from '../scope'
import { useAutoSpeakReplies } from './use-auto-speak-replies'
vi.mock('@/lib/voice-playback', () => ({
playSpeechText: vi.fn()
}))
const SESSION_ID = 'session-under-test'
const IDLE_STATE = { audioElement: null, messageId: null, sequence: 0, source: null, status: 'idle' as const }
function assistantMessage(id: string, text: string): ChatMessage {
return { id, parts: [assistantTextPart(text)], role: 'assistant' }
}
// #93515 — Edge TTS has no chunked-PCM API, so the WS attempt in
// playSpeechText's fallback ladder settles 'fallback' before any audio plays
// and the client retries over the POST endpoint. While that POST round-trip
// is in flight, the backend can rewrite the just-completed reply's renderer
// id (`assistant-stream-*`) to its durable id. The issue claims
// `resolveSpokenReply()` fails to follow that rewrite and the reply gets
// spoken a second time once `$voicePlayback` goes idle.
describe('useAutoSpeakReplies — Edge TTS fallback chain (#93515)', () => {
afterEach(() => {
cleanup()
clearSpokenRepliesForTests()
$autoSpeakReplies.set(false)
setVoicePlaybackState({ ...IDLE_STATE })
vi.clearAllMocks()
})
it('does not re-speak the reply once playback goes idle after an id rewrite mid-fallback', async () => {
$autoSpeakReplies.set(true)
const $messages = atom<ChatMessage[]>([])
// The exact pendingReply/markSpoken contract use-composer-voice.ts wires
// up for this hook, backed by the real ordinal-anchored dedupe.
const pendingReply = () => {
const messages = $messages.get()
const last = messages.findLast(m => m.role === 'assistant' && !m.hidden)
const spoken = resolveSpokenReply(SESSION_ID, messages)
if (!last || last.id === spoken?.id) {
return null
}
return { id: last.id, pending: Boolean(last.pending), text: chatMessageText(last) }
}
const markSpoken = () => {
const messages = $messages.get()
const last = messages.findLast(m => m.role === 'assistant' && !m.hidden)
if (last) {
markAssistantIdSpoken(SESSION_ID, messages, last.id)
}
}
let settleFallback: (() => void) | null = null
vi.mocked(playSpeechText).mockImplementation(async () => {
setVoicePlaybackState({
audioElement: null,
messageId: 'assistant-stream-1',
sequence: 0,
source: 'read-aloud',
status: 'preparing'
})
// Holds mid-ladder — the WS-fallback-then-POST round trip the issue
// describes — until the test rewrites the message id underneath it.
await new Promise<void>(resolve => {
settleFallback = resolve
})
$messages.set([assistantMessage('durable-42', 'hello there')])
setVoicePlaybackState({
audioElement: null,
messageId: 'durable-42',
sequence: 0,
source: 'read-aloud',
status: 'idle'
})
return true
})
renderHook(
() =>
useAutoSpeakReplies({
conversationActive: false,
failureLabel: 'read-aloud failed',
markSpoken,
pendingReply,
sessionId: SESSION_ID
}),
{
wrapper: ({ children }) => (
<ComposerScopeProvider value={{ ...MAIN_COMPOSER_SCOPE, $messages }}>{children}</ComposerScopeProvider>
)
}
)
act(() => {
$messages.set([assistantMessage('assistant-stream-1', 'hello there')])
})
await waitFor(() => expect(playSpeechText).toHaveBeenCalledTimes(1))
await act(async () => {
settleFallback?.()
await Promise.resolve()
await Promise.resolve()
await Promise.resolve()
})
expect($voicePlayback.get().status).toBe('idle')
expect(playSpeechText).toHaveBeenCalledTimes(1)
})
})
@@ -0,0 +1,91 @@
import { useStore } from '@nanostores/react'
import { useEffect, useRef } from 'react'
import { playSpeechText } from '@/lib/voice-playback'
import { ownsAmbientCue } from '@/store/ambient'
import { notifyError } from '@/store/notifications'
import { $voicePlayback } from '@/store/voice-playback'
import { $autoSpeakReplies } from '@/store/voice-prefs'
import { useComposerScope } from '../scope'
interface AutoSpeakReply {
id: string
pending: boolean
text: string
}
interface UseAutoSpeakReplies {
conversationActive: boolean
failureLabel: string
/** Mark the current last reply spoken — shared dedupe with the conversation consumer. */
markSpoken: () => void
/** Latest completed assistant reply, or null; `pending` true while still streaming. */
pendingReply: () => AutoSpeakReply | null
/** Re-arm on session switch so opening a chat never reads its existing last reply. */
sessionId: string | null | undefined
}
/**
* Pure-TTS auto-speak: when `voice.auto_tts` is on, read each completed assistant
* turn aloud — no dictation, no conversation loop. Stays off while a full voice
* conversation runs (it speaks replies itself) and never overlaps clips: a reply
* landing mid-playback is held and spoken on the playback-idle edge. Always reads
* the latest reply, so a backlog collapses to the newest.
*/
export function useAutoSpeakReplies({
conversationActive,
failureLabel,
markSpoken,
pendingReply,
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 }
useEffect(() => {
if (!enabled) {
return undefined
}
// Don't read whatever reply already sits at the bottom when the toggle flips
// on (or a chat opens) — consume it so only later replies are spoken.
latest.current.markSpoken()
const speakLatest = () => {
const { conversationActive, failureLabel, markSpoken, pendingReply } = latest.current
if (conversationActive || $voicePlayback.get().status !== 'idle') {
return
}
const reply = pendingReply()
if (!reply || reply.pending) {
return
}
markSpoken()
// Only one window voices a given reply when the same chat is open in
// several (reply.id is the shared backend message id). markSpoken already
// ran in every window, so peers just stay quiet.
void ownsAmbientCue(`speak:${reply.id}`).then(owns => {
if (owns) {
void playSpeechText(reply.text, { messageId: reply.id, source: 'read-aloud' }).catch(error =>
notifyError(error, failureLabel)
)
}
})
}
// Re-check on a reply completing ($messages) and on the prior clip ending
// ($voicePlayback → idle), which frees us to read the next held reply.
const stops = [$messages.subscribe(speakLatest), $voicePlayback.listen(speakLatest)]
return () => stops.forEach(f => f())
}, [$messages, enabled, sessionId])
}
@@ -0,0 +1,98 @@
import { type MutableRefObject, useCallback } from 'react'
import { listRepoBranches, requestStartWorkSession, startWorkInRepo, switchBranchInRepo } from '@/store/projects'
import { useComposerScope } from '../scope'
interface UseComposerBranchOptions {
clearDraft: () => void
cwd: null | string | undefined
draftRef: MutableRefObject<string>
}
/**
* Branch / worktree engine — the `CodingStatusRow` hand-offs. Each action opens
* a fresh session anchored in a worktree carrying the current composer draft as
* its first turn; clearing here means the draft travels to the new session
* instead of getting stashed under this one. Backend coupling (cwd + the
* projects store) is the only dependency; nothing about ChatBar's render.
*/
export function useComposerBranch({ clearDraft, cwd, draftRef }: UseComposerBranchOptions) {
const scope = useComposerScope()
// Hand a worktree off to the controller: open a fresh session anchored there,
// carrying the composer draft as its first turn. Clearing here means the draft
// travels to the new session instead of getting stashed under this one.
const openInWorktree = useCallback(
(path: string) => {
const text = draftRef.current
clearDraft()
scope.attachments.clear()
requestStartWorkSession(path, text)
},
[clearDraft, draftRef]
)
// Branch off into a NEW worktree (base = branch name, or current HEAD). A
// create failure throws back to the row (which toasts) before we touch the
// draft; a missing cwd / remote backend no-ops (the row hides the affordance).
const handleBranchOff = useCallback(
async (branch: string, base?: string) => {
const repoPath = cwd?.trim()
const result = repoPath && (await startWorkInRepo(repoPath, { base, branch, name: branch }))
if (result) {
openInWorktree(result.path)
}
},
[cwd, openInWorktree]
)
// Convert an EXISTING branch into a fresh worktree + session (no new branch).
// Mirrors handleBranchOff's hand-off: create the worktree, then open a session
// anchored there carrying the draft.
const handleConvertBranch = useCallback(
async (branch: string, path?: null | string, isDefault?: boolean) => {
if (path?.trim()) {
openInWorktree(path)
return
}
const repoPath = cwd?.trim()
if (repoPath && isDefault) {
await switchBranchInRepo(repoPath, branch)
openInWorktree(repoPath)
return
}
const result = repoPath && (await startWorkInRepo(repoPath, { existingBranch: branch }))
if (result) {
openInWorktree(result.path)
}
},
[cwd, openInWorktree]
)
const handleListBranches = useCallback(async () => {
const repoPath = cwd?.trim()
return repoPath ? listRepoBranches(repoPath) : []
}, [cwd])
const handleSwitchBranch = useCallback(
async (branch: string) => {
const repoPath = cwd?.trim()
if (repoPath) {
await switchBranchInRepo(repoPath, branch)
}
},
[cwd]
)
return { handleBranchOff, handleConvertBranch, handleListBranches, handleSwitchBranch, openInWorktree }
}
@@ -0,0 +1,353 @@
import { act, cleanup, render, waitFor } from '@testing-library/react'
import { useLayoutEffect } from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { PaneVisibleContext } from '@/components/pane-shell/pane-visibility'
import { clearSessionDraft, type ComposerAttachment, mainComposerScope, stashSessionDraft } from '@/store/composer'
import { $connection } from '@/store/session'
import { useComposerActions } from '../../hooks/use-composer-actions'
import type { QueueEditState } from '../composer-utils'
import { type ComposerTarget, getActiveComposer, markActiveComposer } from '../focus'
import { type ComposerScope, ComposerScopeProvider, MAIN_COMPOSER_SCOPE } from '../scope'
import { useComposerDraft } from './use-composer-draft'
const mockComposerApi = { setText: vi.fn() }
vi.mock('@assistant-ui/react', () => ({
useAui: () => ({ composer: () => mockComposerApi }),
useAuiState: (selector: (state: { composer: { text: string } }) => unknown) => selector({ composer: { text: '' } }),
useComposerRuntime: () => ({
getState: () => ({ text: '' }),
subscribe: () => () => undefined
})
}))
interface ProbeHarnessProps {
activeQueueSessionKey: string | null
onLayoutSnapshot: (attachments: ComposerAttachment[]) => void
sessionId: string
}
function ProbeHarness({ activeQueueSessionKey, onLayoutSnapshot, sessionId }: ProbeHarnessProps) {
useComposerDraft({
activeQueueSessionKey,
focusKey: null,
inputDisabled: false,
queueEditRef: { current: null as QueueEditState | null },
sessionId
})
// useLayoutEffect fires synchronously right after the DOM commit, BEFORE
// the hook's per-thread scope-swap useEffect (a passive effect) has a
// chance to swap attachmentScope.$attachments over to the new session. A
// synchronous read here — the same read ChatBar's `attachments` prop
// performs at render time — observes the OUTGOING session's attachments.
useLayoutEffect(() => {
onLayoutSnapshot(mainComposerScope.$attachments.get())
})
return null
}
describe('useComposerDraft — attachment scope stays coherent with the committed session on switch (#59305)', () => {
afterEach(() => {
cleanup()
mainComposerScope.clear()
clearSessionDraft('session-A')
clearSessionDraft('session-B')
delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop
vi.unstubAllGlobals()
$connection.set(null)
})
it('clears the outgoing session attachments by the layout phase right after switching sessions', () => {
const attachmentA: ComposerAttachment = { id: 'url-A', kind: 'url', label: 'A' }
stashSessionDraft('session-A', 'hi from A', [attachmentA])
const snapshots: ComposerAttachment[][] = []
const { rerender } = render(
<ProbeHarness activeQueueSessionKey="session-A" onLayoutSnapshot={s => snapshots.push(s)} sessionId="session-A" />
)
// Mount loads session A's stashed attachment into the (module-level) main
// scope — confirms the fixture actually seeded the leak precondition.
expect(mainComposerScope.$attachments.get()).toEqual([attachmentA])
snapshots.length = 0 // drop the initial-mount snapshot; only the switch matters
act(() => {
rerender(
<ProbeHarness
activeQueueSessionKey="session-B"
onLayoutSnapshot={s => snapshots.push(s)}
sessionId="session-B"
/>
)
})
// By the layout phase the scope must already be B's (empty) — a submit
// fired the instant B renders must never ship session A's attachment.
expect(snapshots[0]).toEqual([])
})
it('applies a delayed image preview when it resolves while its attachment draft is inactive', async () => {
const fullResolution =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+GkZcAAAAASUVORK5CYII='
const readFileDataUrl = vi.fn(async () => fullResolution)
;(
window as unknown as {
hermesDesktop: { readFileDataUrl: typeof readFileDataUrl }
}
).hermesDesktop = { readFileDataUrl }
let resolveBitmap!: (bitmap: { close: () => void; height: number; width: number }) => void
vi.stubGlobal(
'fetch',
vi.fn(async () => ({ blob: async () => new Blob([new Uint8Array([0])], { type: 'image/png' }) }))
)
vi.stubGlobal(
'createImageBitmap',
vi.fn(
() =>
new Promise<{ close: () => void; height: number; width: number }>(resolve => {
resolveBitmap = resolve
})
)
)
class MockOffscreenCanvas {
getContext = () => ({ drawImage: vi.fn() })
convertToBlob = vi.fn(async () => new Blob(['thumbnail'], { type: 'image/png' }))
constructor(_width: number, _height: number) {}
}
vi.stubGlobal('OffscreenCanvas', MockOffscreenCanvas)
let actions!: ReturnType<typeof useComposerActions>
function PreviewHarness({ activeQueueSessionKey }: { activeQueueSessionKey: string }) {
useComposerDraft({
activeQueueSessionKey,
focusKey: null,
inputDisabled: false,
queueEditRef: { current: null as QueueEditState | null },
sessionId: activeQueueSessionKey
})
actions = useComposerActions({ activeSessionId: null, currentCwd: '', requestGateway: vi.fn() })
return null
}
const { rerender } = render(<PreviewHarness activeQueueSessionKey="session-A" />)
let pending!: Promise<boolean>
act(() => {
pending = actions.attachImagePath('/tmp/round-trip.png')
})
await waitFor(() => expect(createImageBitmap).toHaveBeenCalledOnce())
act(() => rerender(<PreviewHarness activeQueueSessionKey="session-B" />))
expect(mainComposerScope.$attachments.get()).toEqual([])
resolveBitmap({ close: vi.fn(), height: 3000, width: 4000 })
await act(async () => {
await pending
})
// The late completion belongs to A and must not leak into active session B.
expect(mainComposerScope.$attachments.get()).toEqual([])
act(() => rerender(<PreviewHarness activeQueueSessionKey="session-A" />))
expect(mainComposerScope.$attachments.get()[0]?.thumbnailUrl).toMatch(/^data:image\/png;base64,/)
})
})
describe('useComposerDraft — rehydrate diagnostic log stays redacted', () => {
afterEach(() => {
cleanup()
mainComposerScope.clear()
vi.restoreAllMocks()
})
it('logs counts/kinds/scope on restore but never the raw url, refText, or label', () => {
const secretUrl = 'https://secret.example.com/private-workspace-path'
const attachment: ComposerAttachment = {
id: 'url-secret',
kind: 'url',
label: 'do-not-leak-label',
refText: `@url:${secretUrl}`
}
stashSessionDraft('session-secret', '', [attachment])
const debugSpy = vi.spyOn(console, 'debug').mockImplementation(() => undefined)
render(
<ProbeHarness
activeQueueSessionKey="session-secret"
onLayoutSnapshot={() => undefined}
sessionId="session-secret"
/>
)
const rehydrateCalls = debugSpy.mock.calls.filter(call => call[0] === '[composer-rehydrate]')
expect(rehydrateCalls.length).toBeGreaterThan(0)
const serialized = JSON.stringify(rehydrateCalls)
expect(serialized).not.toContain(secretUrl)
expect(serialized).not.toContain(attachment.label)
expect(serialized).not.toContain(attachment.refText)
expect(rehydrateCalls[0]?.[1]).toMatchObject({
attachmentCount: 1,
attachmentKinds: ['url'],
scope: 'session-secret'
})
})
})
describe('useComposerDraft — draft survives full unmount (Settings navigation, #41079)', () => {
afterEach(() => {
cleanup()
mainComposerScope.clear()
clearSessionDraft('session-nav')
})
it('stashes the unsent draft on unmount and restores it on remount', () => {
// The user typed but has not sent; the draft was stashed by the normal
// typing debounce path at some earlier point.
stashSessionDraft('session-nav', 'unsent thought', [])
const { unmount } = render(
<ProbeHarness activeQueueSessionKey="session-nav" onLayoutSnapshot={() => undefined} sessionId="session-nav" />
)
// Navigating to Settings unmounts the chat composer entirely. The swap
// effect's cleanup must stash the loaded draft back under its scope —
// NOT drop it with the React state.
unmount()
mockComposerApi.setText.mockClear()
const remount = render(
<ProbeHarness activeQueueSessionKey="session-nav" onLayoutSnapshot={() => undefined} sessionId="session-nav" />
)
// Remount restored the text into the composer core (setText mirrors
// paintDraft's write path — the editor DOM isn't mounted in this harness).
expect(mockComposerApi.setText).toHaveBeenCalledWith('unsent thought')
remount.unmount()
})
})
describe('useComposerDraft — a closing composer hands the focus-bus key back', () => {
afterEach(() => {
cleanup()
mainComposerScope.clear()
markActiveComposer('main')
})
function renderScoped(target: ComposerTarget) {
const scope: ComposerScope = { ...MAIN_COMPOSER_SCOPE, target }
return render(
<ComposerScopeProvider value={scope}>
<ProbeHarness
activeQueueSessionKey="session-tile"
onLayoutSnapshot={() => undefined}
sessionId="session-tile"
/>
</ComposerScopeProvider>
)
}
it('stops `active` resolving to a session tile once the tile unmounts', () => {
const { unmount } = renderScoped('tile:abc')
// Mounting claims the bus for this tile — the leak precondition.
expect(getActiveComposer()).toBe('tile:abc')
unmount()
expect(getActiveComposer()).toBe('main')
})
it('leaves the key alone when another composer claimed it before this one unmounted', () => {
const { unmount } = renderScoped('tile:abc')
expect(getActiveComposer()).toBe('tile:abc')
// The user clicks into a second tile, which claims the bus.
markActiveComposer('tile:other')
unmount()
expect(getActiveComposer()).toBe('tile:other')
})
})
describe('useComposerDraft — a hidden keep-alive tab never auto-focuses its composer', () => {
afterEach(() => {
cleanup()
mainComposerScope.clear()
markActiveComposer('main')
})
function renderScopedHidden(target: ComposerTarget, hidden: boolean) {
const scope: ComposerScope = { ...MAIN_COMPOSER_SCOPE, target }
return render(
<PaneVisibleContext.Provider value={!hidden}>
<ComposerScopeProvider value={scope}>
<ProbeHarness
activeQueueSessionKey="session-tile"
onLayoutSnapshot={() => undefined}
sessionId="session-tile"
/>
</ComposerScopeProvider>
</PaneVisibleContext.Provider>
)
}
it('does not claim the focus bus when the composer mounts inside a hidden pane', () => {
renderScopedHidden('tile:bg', true)
expect(getActiveComposer()).toBe('main')
})
it('still claims the bus when the same composer becomes visible', () => {
const { rerender } = renderScopedHidden('tile:fg', true)
expect(getActiveComposer()).toBe('main')
rerender(
<PaneVisibleContext.Provider value={true}>
<ComposerScopeProvider value={{ ...MAIN_COMPOSER_SCOPE, target: 'tile:fg' }}>
<ProbeHarness
activeQueueSessionKey="session-tile"
onLayoutSnapshot={() => undefined}
sessionId="session-tile"
/>
</ComposerScopeProvider>
</PaneVisibleContext.Provider>
)
expect(getActiveComposer()).toBe('tile:fg')
})
it('claims the bus on mount when visible', () => {
renderScopedHidden('tile:vis', false)
expect(getActiveComposer()).toBe('tile:vis')
})
})
@@ -0,0 +1,511 @@
// Register the built-in draft providers with the suggestion bus (side-effect
// import — the bus itself is provider-agnostic). The repair provider is
// event-driven and registers through the gateway stream instead.
import '@/store/suggestion-providers/cron'
import '@/store/suggestion-providers/github'
import '@/store/suggestion-providers/mcp'
import '@/store/suggestion-providers/skill'
import { useAui, useAuiState, useComposerRuntime } from '@assistant-ui/react'
import { type RefObject, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
import { usePaneVisible } from '@/components/pane-shell/pane-visibility'
import { SLASH_COMMAND_RE } from '@/lib/chat-runtime'
import { sanitizeComposerInput } from '@/lib/composer-input-sanitize'
import {
type ComposerAttachment,
type ComposerDraftSyncMode,
onComposerDraftSyncRequest,
reloadPersistedDrafts,
stashSessionDraft,
takeSessionDraft
} from '@/store/composer'
import { isBrowsingHistory } from '@/store/composer-input-history'
import { clearDraftSuggestions, sampleComposerDraft } from '@/store/composer-suggestions'
import {
cloneAttachments,
DRAFT_PERSIST_DEBOUNCE_MS,
isPendingDraftPersistCurrent,
type QueueEditState
} from '../composer-utils'
import {
type ComposerInsertMode,
focusComposerInput,
markActiveComposer,
onComposerFocusRequest,
onComposerInsertRefsRequest,
onComposerInsertRequest,
releaseActiveComposer
} from '../focus'
import { type InlineRefInput, insertInlineRefsIntoEditor } from '../inline-refs'
import {
composerPlainText,
normalizeComposerEditorDom,
placeCaretEnd,
REF_RE,
renderComposerContents
} from '../rich-editor'
import { useComposerScope } from '../scope'
import type { ChatBarProps } from '../types'
interface UseComposerDraftArgs {
activeQueueSessionKey: string | null
focusKey: ChatBarProps['focusKey']
inputDisabled: boolean
queueEditRef: RefObject<QueueEditState | null>
sessionId: string | null | undefined
}
/**
* The composer's draft engine — the detached source-of-truth spine. The live
* text lives in the contentEditable DOM + `draftRef`; React only sees coarse
* edge selectors, so typing never re-renders the chrome. Owns the imperative
* composer-runtime subscription (draftRef mirror + external repaint + debounced
* per-session stash), the edit primitives (append/insert/inline-refs), focus,
* and per-session load/clear/stash/restore. The contentEditable *event*
* handlers stay in ChatBar (they bridge into the trigger engine) and drive the
* primitives exposed here.
*/
export function useComposerDraft({
activeQueueSessionKey,
focusKey,
inputDisabled,
queueEditRef,
sessionId
}: UseComposerDraftArgs) {
const aui = useAui()
const composerRuntime = useComposerRuntime()
const paneVisible = usePaneVisible()
// Which composer this is on the focus bus + which attachment set it owns.
const { attachments: attachmentScope, target } = useComposerScope()
// Coarse edges only — these flip rarely (empty↔non-empty, the `?` help sigil,
// steerable-vs-slash), so typing within a line costs no render.
const hasText = useAuiState(s => s.composer.text.trim().length > 0)
const isHelpHint = useAuiState(s => s.composer.text === '?')
const isSteerableText = useAuiState(s => {
const trimmed = s.composer.text.trim()
return trimmed.length > 0 && !SLASH_COMMAND_RE.test(trimmed)
})
// assistant-ui's composer mutators throw when the core isn't bound yet (a
// startup/thread-swap window); the DOM + draftRef hold the text and the
// subscription reconciles once it binds, so swallow the premature write.
const setComposerText = useCallback(
(value: string) => {
try {
aui.composer().setText(value)
} catch {
// Composer core not bound yet — DOM/draftRef carry the text.
}
},
[aui]
)
const editorRef = useRef<HTMLDivElement | null>(null)
const draftRef = useRef('')
const pendingDraftPersistRef = useRef<{ scope: string | null; text: string } | null>(null)
const draftPersistTimerRef = useRef<number | undefined>(undefined)
const activeQueueSessionKeyRef = useRef(activeQueueSessionKey)
activeQueueSessionKeyRef.current = activeQueueSessionKey
// Owned only by the swap effect below — unlike activeQueueSessionKeyRef this
// does NOT update on every render, so it always reflects the session whose
// text is actually loaded in the editor. Async work (debounce timers,
// pagehide flush) must persist against this, not the render-time ref, or a
// session switch mid-flight files one session's draft under another's key
// (#54527).
const draftScopeRef = useRef(activeQueueSessionKey)
const sessionIdRef = useRef(sessionId)
sessionIdRef.current = sessionId
const queueEditStateRef = useRef<QueueEditState | null>(queueEditRef.current)
queueEditStateRef.current = queueEditRef.current
const [focusRequestId, setFocusRequestId] = useState(0)
const focusInput = useCallback(() => {
focusComposerInput(editorRef.current)
markActiveComposer(target)
}, [target])
const requestMainFocus = useCallback(() => {
setFocusRequestId(id => id + 1)
}, [])
// The single write path for programmatic draft mutations: mirror → AUI state →
// repaint the editor (caret to end). Repaints even while focused — inserts /
// restores run mid-focus, and the runtime sync only repaints an unfocused
// editor — so the visible text never lags the store.
const paintDraft = useCallback(
(next: string, focus = true) => {
draftRef.current = next
setComposerText(next)
const editor = editorRef.current
if (editor) {
renderComposerContents(editor, next, { trailingCommitted: true })
placeCaretEnd(editor)
}
if (focus) {
requestMainFocus()
}
},
[requestMainFocus, setComposerText]
)
const appendExternalText = useCallback(
(text: string, mode: ComposerInsertMode) => {
const value = text.trim()
if (!value) {
return
}
// 'prefix' puts the value at the START of the draft — slash commands
// (the skill-suggestion pill) only route when they lead the message.
if (mode === 'prefix') {
const rest = draftRef.current.trimStart()
paintDraft(`${value} ${rest}`.trimEnd())
return
}
const base = mode === 'inline' ? draftRef.current.trimEnd() : draftRef.current
const sep = mode === 'inline' ? (base ? ' ' : '') : base && !base.endsWith('\n') ? '\n\n' : ''
paintDraft(`${base}${sep}${value}`)
},
[paintDraft]
)
// Keep-alive tabs keep this composer mounted. A background session whose
// turn finished or recovered from a reconnect would otherwise re-run this
// effect and steal the caret. usePaneVisible defaults true outside a tab
// stack, so tiles, pop-outs, and secondary windows still auto-focus.
useEffect(() => {
if (!inputDisabled && paneVisible) {
focusInput()
}
}, [focusInput, focusKey, focusRequestId, inputDisabled, paneVisible])
// The mirror of the `markActiveComposer` above: give the key back when this
// composer goes away (a session tile closing, a pane unmounting). Covers both
// claim sites for this composer — `focusInput` here and ChatBar's `onFocus` —
// since they mark the same scope target. Without it `'active'` keeps
// resolving to a dead tile and every routed focus/insert request is dropped.
// (Heal-to-visible in focus.ts covers the keep-alive-tab case where the pane
// stays mounted behind the front tab; this covers true unmounts.)
useEffect(() => () => releaseActiveComposer(target), [target])
useEffect(() => {
if (inputDisabled) {
return undefined
}
const offFocus = onComposerFocusRequest(({ target: requested, typeChar }) => {
if (requested !== target) {
return
}
// Type-to-focus appends at end; bare Enter just focuses.
if (typeChar) {
paintDraft(`${draftRef.current}${typeChar}`, true)
return
}
setFocusRequestId(id => id + 1)
})
const offInsert = onComposerInsertRequest(({ mode, target: requested, text }) => {
if (requested === target) {
appendExternalText(text, mode)
}
})
return () => {
offFocus()
offInsert()
}
}, [appendExternalText, inputDisabled, paintDraft, target])
const stashAt = (scope: string | null, text = draftRef.current, attachments = attachmentScope.$attachments.get()) =>
stashSessionDraft(scope, text, attachments)
const loadIntoComposer = (text: string, attachments: ComposerAttachment[]) => {
// Diagnostic breadcrumb for #59305-class reports: identifies WHAT kind of
// state got restored into the composer (session switch, queue-edit
// restore, history browse) without logging any raw content. REF_RE has the
// global flag — testing against a throwaway clone avoids mutating the
// shared instance's lastIndex, which would otherwise corrupt this check on
// the next call.
if (attachments.length > 0 || new RegExp(REF_RE.source, REF_RE.flags).test(text)) {
console.debug('[composer-rehydrate]', {
attachmentCount: attachments.length,
attachmentKinds: attachments.map(a => a.kind),
hasTextRefs: new RegExp(REF_RE.source, REF_RE.flags).test(text),
scope: activeQueueSessionKeyRef.current
})
}
attachmentScope.$attachments.set(cloneAttachments(attachments))
paintDraft(text, false)
}
const clearDraft = useCallback(() => {
setComposerText('')
draftRef.current = ''
if (editorRef.current) {
renderComposerContents(editorRef.current, '')
placeCaretEnd(editorRef.current)
}
}, [setComposerText])
// Read the editor's current plain text into draftRef + composer state. This
// closes the "queued rAF flush hasn't run yet" window so scope-swap/pagehide
// persistence captures the latest keystrokes.
const syncDraftFromEditor = useCallback(() => {
const editor = editorRef.current
if (!editor) {
return draftRef.current
}
// Same normalize-then-sanitize the rAF flush does. An emptied editor still
// holds the placeholder <br> that keeps the contenteditable from collapsing
// to a sliver, and that serializes as "\n" — so an editor the user just
// cleared would otherwise stash a one-newline draft and come back non-empty.
normalizeComposerEditorDom(editor)
const text = sanitizeComposerInput(composerPlainText(editor))
if (text !== draftRef.current) {
draftRef.current = text
setComposerText(text)
}
return text
}, [setComposerText])
// Imperative draft sync — the spine of the "work only when work is to be
// performed" model. Subscribing to the composer runtime directly (not
// `useAuiState(text)` + a `[draft]` effect) keeps per-keystroke text out of
// React, so typing never re-renders the chrome. On each change we (1) mirror
// text into draftRef, (2) repaint the editor only when the change came from
// OUTSIDE it (programmatic clear/restore/insert; the focused editor is the
// source otherwise), and (3) schedule the debounced per-session stash.
// Browsing history / editing a queued prompt suppress the stash so recalled
// text never clobbers the draft.
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
useEffect(() => {
const sync = () => {
const text = composerRuntime.getState().text
draftRef.current = text
// Composer suggestion pills for THIS session's draft (debounced +
// change-gated in the bus — this is just a timer reset).
sampleComposerDraft(sessionIdRef.current ?? null, text)
const editor = editorRef.current
if (editor && document.activeElement !== editor && composerPlainText(editor) !== text) {
renderComposerContents(editor, text, { trailingCommitted: true })
}
if (isBrowsingHistory(sessionIdRef.current) || queueEditRef.current) {
return
}
const scope = draftScopeRef.current
const entry = { scope, text }
pendingDraftPersistRef.current = entry
window.clearTimeout(draftPersistTimerRef.current)
draftPersistTimerRef.current = window.setTimeout(() => {
// Integrity guard (defense-in-depth, #54527): only commit if this is
// still the pending write on file. A session swap or a newer
// keystroke clears/replaces it before firing in the normal case; this
// catches any future call site that skips that bookkeeping instead of
// silently filing text under the wrong session.
if (!isPendingDraftPersistCurrent(pendingDraftPersistRef.current, entry)) {
return
}
pendingDraftPersistRef.current = null
stashAt(scope, text)
}, DRAFT_PERSIST_DEBOUNCE_MS)
}
const unsubscribe = composerRuntime.subscribe(sync)
return () => {
unsubscribe()
window.clearTimeout(draftPersistTimerRef.current)
}
}, [composerRuntime, queueEditRef])
const insertText = (text: string) => {
const base = draftRef.current
const sep = base && !base.endsWith('\n') ? '\n' : ''
paintDraft(`${base}${sep}${text}`)
}
// insertInlineRefs mutates the editor in place (chips), so it can't go through
// paintDraft's re-render — it mirrors the resulting plain text and refocuses.
const insertInlineRefs = (refs: InlineRefInput[]) => {
const editor = editorRef.current
if (!editor) {
return false
}
const nextDraft = insertInlineRefsIntoEditor(editor, refs)
if (nextDraft === null) {
return false
}
draftRef.current = nextDraft
setComposerText(nextDraft)
requestMainFocus()
return true
}
// Latest-closure ref so the once-only subscription always calls the current
// insertInlineRefs without re-subscribing every render.
const insertInlineRefsRef = useRef(insertInlineRefs)
insertInlineRefsRef.current = insertInlineRefs
useEffect(() => {
return onComposerInsertRefsRequest(({ refs, target: requested }) => {
if (requested === target) {
insertInlineRefsRef.current(refs)
}
})
}, [target])
// Per-thread draft swap — the composer's only session coupling. Lifecycle
// never clears composer state; this effect alone stashes on leave, restores
// on enter. Keyed writes are idempotent, so no skip-sentinel.
//
// MUST be a layout effect, not a passive one: it swaps attachmentScope's
// module-level $attachments atom, and a passive effect fires only after the
// browser paints the new session's view — leaving a window where the DOM
// already shows session B while $attachments (and therefore ChatBar's
// `attachments` prop) still holds session A's chips. A submit fired in that
// window (e.g. a fast session switch immediately followed by Enter) would
// ship A's attachments into B's turn (#59305). useLayoutEffect closes the
// window by running before paint.
useLayoutEffect(() => {
// A pending debounce timer from the outgoing session is now stale — its
// scope was correct when scheduled, but the authoritative stash below
// (and the cleanup on the way out) already covers that text. Letting it
// fire later would just clobber with an older snapshot.
window.clearTimeout(draftPersistTimerRef.current)
pendingDraftPersistRef.current = null
draftScopeRef.current = activeQueueSessionKey
const { attachments, text } = takeSessionDraft(activeQueueSessionKey)
loadIntoComposer(text, attachments)
return () => {
const latestText = syncDraftFromEditor()
const editing = queueEditStateRef.current
if (editing?.sessionKey === activeQueueSessionKey) {
stashAt(activeQueueSessionKey, editing.draft, editing.attachments)
} else if (!isBrowsingHistory(sessionId)) {
stashAt(activeQueueSessionKey, latestText)
}
// Withdraw the outgoing session's draft suggestions (and any pending
// sample timer). The incoming session re-earns its own from the draft
// restore above — without this a leaving session's "Add GitHub" pill
// lingers in the map and re-appears stale on the way back.
clearDraftSuggestions(sessionIdRef.current)
}
}, [activeQueueSessionKey]) // eslint-disable-line react-hooks/exhaustive-deps
// The HUD handoff's two verbs. Entering HUD mode flushes this editor's text
// into the shared stash so the HUD's composer boots with it; leaving repaints
// from the stash so whatever the HUD typed (or sent, clearing it) is what the
// app window shows. The per-session swap effect above can't cover either one:
// the session scope doesn't change, so it never re-consults the stash.
const syncDraft = (mode: ComposerDraftSyncMode) => {
if (mode === 'flush') {
window.clearTimeout(draftPersistTimerRef.current)
pendingDraftPersistRef.current = null
stashAt(draftScopeRef.current, syncDraftFromEditor())
return
}
reloadPersistedDrafts()
const stashed = takeSessionDraft(draftScopeRef.current)
loadIntoComposer(stashed.text, stashed.attachments)
}
const syncDraftRef = useRef(syncDraft)
syncDraftRef.current = syncDraft
useEffect(
() =>
onComposerDraftSyncRequest(({ mode, target: requested }) => {
if (requested === target) {
syncDraftRef.current(mode)
}
}),
[target]
)
// pagehide is load-bearing: React skips effect cleanups on reload, so Cmd+R
// inside the debounce/rAF window would drop trailing keystrokes without this.
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
useEffect(() => {
const flushPendingDraftPersist = () => {
const scope = draftScopeRef.current
const editing = queueEditStateRef.current
if (editing?.sessionKey === scope || isBrowsingHistory(sessionIdRef.current)) {
return
}
const latestText = syncDraftFromEditor()
pendingDraftPersistRef.current = null
stashAt(scope, latestText)
}
window.addEventListener('pagehide', flushPendingDraftPersist)
return () => {
window.removeEventListener('pagehide', flushPendingDraftPersist)
flushPendingDraftPersist()
}
}, [syncDraftFromEditor])
return {
activeQueueSessionKeyRef,
clearDraft,
draftRef,
editorRef,
focusInput,
hasText,
insertInlineRefs,
insertText,
isHelpHint,
isSteerableText,
loadIntoComposer,
requestMainFocus,
sessionIdRef,
setComposerText,
stashAt,
syncDraftFromEditor
}
}
@@ -0,0 +1,164 @@
import { type DragEvent as ReactDragEvent, useRef, useState } from 'react'
import { triggerHaptic } from '@/lib/haptics'
import { extractDroppedFiles, HERMES_PATHS_MIME, partitionDroppedFiles } from '../../hooks/use-composer-actions'
import { dragHasAttachments, droppedFileInlineRefs, type InlineRefInput } from '../inline-refs'
import type { ChatBarProps } from '../types'
interface UseComposerDropArgs {
cwd: ChatBarProps['cwd']
insertInlineRefs: (refs: InlineRefInput[]) => boolean
onAttachDroppedItems: ChatBarProps['onAttachDroppedItems']
requestMainFocus: () => void
}
/**
* Drag-and-drop attachment engine. Splits drops by origin: in-app drags
* (project tree / gutter) stay inline `@file:`/`@line:` refs the gateway
* resolves directly; OS/Finder drops (absolute local paths a remote gateway
* can't read, image bytes vision needs) route through the upload pipeline.
* Off the keystroke path; consumes `insertInlineRefs` + the attach handler.
*/
export function useComposerDrop({
cwd,
insertInlineRefs,
onAttachDroppedItems,
requestMainFocus
}: UseComposerDropArgs) {
const [dragActive, setDragActive] = useState(false)
const dragDepthRef = useRef(0)
const resetDragState = () => {
dragDepthRef.current = 0
setDragActive(false)
}
const handleDragEnter = (event: ReactDragEvent<HTMLFormElement>) => {
if (!onAttachDroppedItems || !dragHasAttachments(event.dataTransfer, HERMES_PATHS_MIME)) {
return
}
event.preventDefault()
dragDepthRef.current += 1
if (!dragActive) {
setDragActive(true)
}
}
const handleDragOver = (event: ReactDragEvent<HTMLFormElement>) => {
if (!onAttachDroppedItems || !dragHasAttachments(event.dataTransfer, HERMES_PATHS_MIME)) {
return
}
event.preventDefault()
event.dataTransfer.dropEffect = 'copy'
}
const handleDragLeave = (event: ReactDragEvent<HTMLFormElement>) => {
if (!onAttachDroppedItems) {
return
}
event.preventDefault()
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1)
if (dragDepthRef.current === 0) {
setDragActive(false)
}
}
const handleDrop = (event: ReactDragEvent<HTMLFormElement>) => {
if (!onAttachDroppedItems) {
return
}
event.preventDefault()
resetDragState()
const candidates = extractDroppedFiles(event.dataTransfer)
if (candidates.length === 0) {
return
}
// In-app drags (project tree / gutter) are workspace-relative paths the
// gateway resolves directly, so they stay inline @file:/@line: refs. OS
// drops are absolute local paths a remote gateway can't read (and images
// need byte upload for vision), so route them through the upload pipeline.
const { inAppRefs, osDrops } = partitionDroppedFiles(candidates)
const refs = droppedFileInlineRefs(inAppRefs, cwd)
if (refs.length && insertInlineRefs(refs)) {
triggerHaptic('selection')
}
if (osDrops.length) {
void Promise.resolve(onAttachDroppedItems(osDrops)).then(attached => {
if (attached) {
triggerHaptic('selection')
requestMainFocus()
}
})
}
}
const handleInputDragOver = (event: ReactDragEvent<HTMLDivElement>) => {
if (!dragHasAttachments(event.dataTransfer, HERMES_PATHS_MIME)) {
return
}
event.preventDefault()
event.stopPropagation()
event.dataTransfer.dropEffect = 'copy'
}
const handleInputDrop = (event: ReactDragEvent<HTMLDivElement>) => {
if (!dragHasAttachments(event.dataTransfer, HERMES_PATHS_MIME)) {
return
}
const candidates = extractDroppedFiles(event.dataTransfer)
if (!candidates.length) {
return
}
event.preventDefault()
event.stopPropagation()
resetDragState()
// Dropping straight onto the text box used to inline-ref *every* file —
// including OS/Finder drops, whose absolute local path a remote gateway
// can't read and whose image bytes never reached vision. Split by origin:
// in-app drags stay inline refs; OS drops go through the upload pipeline.
// (When no upload handler is wired, fall back to inline refs for all.)
const attach = onAttachDroppedItems
const { inAppRefs, osDrops } = partitionDroppedFiles(candidates)
const refs = droppedFileInlineRefs(attach ? inAppRefs : candidates, cwd)
if (refs.length && insertInlineRefs(refs)) {
triggerHaptic('selection')
}
if (attach && osDrops.length) {
void Promise.resolve(attach(osDrops)).then(attached => {
if (attached) {
triggerHaptic('selection')
requestMainFocus()
}
})
}
}
return {
dragActive,
handleDragEnter,
handleDragLeave,
handleDragOver,
handleDrop,
handleInputDragOver,
handleInputDrop
}
}
@@ -0,0 +1,50 @@
import { cleanup, fireEvent, render } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { useComposerEscCancel } from './use-composer-esc-cancel'
function Harness({ busy, onCancel }: { busy: boolean; onCancel: () => void }) {
useComposerEscCancel({ awaitingInput: false, busy, onCancel, target: 'main' })
return null
}
afterEach(() => {
cleanup()
document.body.innerHTML = ''
})
describe('useComposerEscCancel', () => {
it('cancels the turn on Esc while busy with chat focus', () => {
const onCancel = vi.fn()
render(<Harness busy onCancel={onCancel} />)
fireEvent.keyDown(window, { key: 'Escape' })
expect(onCancel).toHaveBeenCalledTimes(1)
})
it('does not cancel on Esc when the turn is not busy', () => {
const onCancel = vi.fn()
render(<Harness busy={false} onCancel={onCancel} />)
fireEvent.keyDown(window, { key: 'Escape' })
expect(onCancel).not.toHaveBeenCalled()
})
it('does not cancel on Esc while an overlay surface covers the chat', () => {
const onCancel = vi.fn()
render(<Harness busy onCancel={onCancel} />)
// OverlayView stamps `data-overlay-surface` on its root — the same signal
// the type-to-focus path uses to stand down while an overlay is open.
const overlay = document.createElement('div')
overlay.setAttribute('data-overlay-surface', '')
document.body.appendChild(overlay)
fireEvent.keyDown(window, { key: 'Escape' })
expect(onCancel).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,69 @@
import { useEffect, useRef } from 'react'
import { triggerHaptic } from '@/lib/haptics'
import { composerFocusBlockedBySurface } from '@/lib/keybinds/composer-focus-keys'
import { type ComposerTarget, getActiveComposer } from '../focus'
interface UseComposerEscCancelOptions {
awaitingInput: boolean
busy: boolean
onCancel: () => unknown
/** This composer's focus-bus key. With N composers mounted (main + tiles),
* only the active one's Esc cancels — otherwise every busy tile stops. */
target: ComposerTarget
}
/**
* Global Esc-to-cancel: stop the in-flight turn when the CHAT (not the composer
* input, which has its own handler) has focus — clicking into the transcript and
* hitting Esc stops the run, matching the Stop button. A latest-handler ref keeps
* the window listener registered exactly once while still reading fresh
* busy/awaitingInput/onCancel each press.
*/
export function useComposerEscCancel({ awaitingInput, busy, onCancel, target }: UseComposerEscCancelOptions) {
// Intentional only: we bail if (a) the composer/another field already handled
// Esc (defaultPrevented), (b) focus is in any input/textarea/contenteditable
// (you're typing, not stopping), or (c) a dialog/popover is open — Esc must
// close that overlay, never double as canceling the stream behind it.
const escCancelRef = useRef<(event: globalThis.KeyboardEvent) => void>(() => {})
escCancelRef.current = (event: globalThis.KeyboardEvent) => {
// `awaitingInput`: the turn is parked on a clarify / approval / sudo / secret
// prompt, which owns Esc (or is meant to persist) — never cancel the stream
// out from under it.
if (event.key !== 'Escape' || event.defaultPrevented || !busy || awaitingInput) {
return
}
// Only the focused composer cancels — otherwise every mounted busy tile
// stops at once (and the winner would be mount-order arbitrary).
if (getActiveComposer() !== target) {
return
}
const active = document.activeElement as HTMLElement | null
if (active && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA' || active.isContentEditable)) {
return
}
// An overlay covering the chat owns Esc (its escape layer closes it) —
// the composer stays mounted beneath it, so stand down. Same surface
// signal as type-to-focus; also covers Radix dialogs/popovers.
if (composerFocusBlockedBySurface()) {
return
}
event.preventDefault()
triggerHaptic('cancel')
void Promise.resolve(onCancel())
}
useEffect(() => {
const onKeyDown = (event: globalThis.KeyboardEvent) => escCancelRef.current(event)
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [])
}
@@ -0,0 +1,228 @@
import { useAuiState } from '@assistant-ui/react'
import { type RefObject, useCallback, useEffect, useRef, useState } from 'react'
import {
chatSurfaceRoot,
clearSurfaceVar,
COMPOSER_HEIGHT_VAR,
COMPOSER_SURFACE_HEIGHT_VAR,
setSurfaceVar
} from '@/app/chat/surface-vars'
import { useResizeObserver } from '@/hooks/use-resize-observer'
import {
COMPOSER_COMPACT_PILL_PX,
COMPOSER_FOLD_VOICE_PX,
COMPOSER_MINIMAL_PX,
COMPOSER_SINGLE_LINE_MAX_PX,
COMPOSER_STACK_BREAKPOINT_PX
} from '../composer-utils'
interface UseComposerMetricsArgs {
composerDockRef: RefObject<HTMLDivElement | null>
composerRef: RefObject<HTMLFormElement | null>
composerSurfaceRef: RefObject<HTMLDivElement | null>
editorRef: RefObject<HTMLDivElement | null>
poppedOut: boolean
}
/** Every width-driven collapse stage, resolved from the composer's own width. */
export interface ComposerFit {
compactPill: boolean
foldVoice: boolean
minimal: boolean
tight: boolean
}
const ROOMY: ComposerFit = { compactPill: false, foldVoice: false, minimal: false, tight: false }
const fitForWidth = (width: number): ComposerFit => ({
compactPill: width < COMPOSER_COMPACT_PILL_PX,
foldVoice: width < COMPOSER_FOLD_VOICE_PX,
minimal: width < COMPOSER_MINIMAL_PX,
tight: width < COMPOSER_STACK_BREAKPOINT_PX
})
const sameFit = (a: ComposerFit, b: ComposerFit) =>
a.compactPill === b.compactPill && a.foldVoice === b.foldVoice && a.minimal === b.minimal && a.tight === b.tight
interface UseComposerMetricsResult extends ComposerFit {
stacked: boolean
}
/**
* Owns the composer's *sizing* engine: the stacked-vs-inline layout decision
* and the measured-height CSS vars the thread reads for bottom clearance. All
* work is edge-gated — the ResizeObserver only fires on real size changes, the
* height vars are 8px-bucketed so per-keystroke growth never invalidates the
* tree's computed style, and the fit only re-renders when it crosses a stage.
*/
export function useComposerMetrics({
composerDockRef,
composerRef,
composerSurfaceRef,
editorRef,
poppedOut
}: UseComposerMetricsArgs): UseComposerMetricsResult {
const [expanded, setExpanded] = useState(false)
const [fit, setFit] = useState<ComposerFit>(ROOMY)
// Edge signals, not the live text: these only re-render when emptiness / the
// presence of a non-trailing newline actually flips, so typing within a line
// costs nothing here.
const isEmpty = useAuiState(s => s.composer.text.length === 0)
const hasHardNewline = useAuiState(s => s.composer.text.trimEnd().includes('\n'))
// Expansion (input on its own full-width row, controls below) is driven by
// the editor's *actual* rendered height via the ResizeObserver in
// syncComposerMetrics — it only fires when the text genuinely wraps to a
// second line, so the layout flips exactly at the wrap point rather than at
// a guessed character count. We only handle the two cases the observer
// can't: an explicit newline (expand before layout settles) and an emptied
// draft (collapse back). We never read scrollHeight per keystroke.
useEffect(() => {
if (isEmpty) {
setExpanded(false)
return
}
if (expanded) {
return
}
// Only a non-trailing newline forces an immediate expand. A trailing newline
// (or phantom \n from contenteditable junk) is left to the ResizeObserver,
// which expands only when the editor's real height actually grows.
if (hasHardNewline) {
setExpanded(true)
}
}, [expanded, hasHardNewline, isEmpty])
// Bucket measured heights so we only invalidate the global CSS var when
// the size crosses a meaningful threshold. Without bucketing, the editor
// grows ~1px per character → setProperty fires every keystroke → entire
// tree's computed style is invalidated → next paint forces a full
// recalculate-style pass. With an 8px bucket, the invalidation rate drops
// ~8× and small char-by-char typing produces no style invalidation at all
// until a wrap or row change actually happens.
const lastBucketedHeightRef = useRef(0)
const lastBucketedSurfaceHeightRef = useRef(0)
const lastFitRef = useRef(ROOMY)
// Mirrored into a ref so `syncComposerMetrics` stays referentially stable —
// it's the shared ResizeObserver's handler, and a new identity every render
// would re-register the observation.
const poppedOutRef = useRef(poppedOut)
poppedOutRef.current = poppedOut
const syncComposerMetrics = useCallback(() => {
const composer = composerRef.current
// The dock is the full docked footprint — strips, status stack, composer —
// so it, not the composer alone, is what the thread has to clear.
const dock = composerDockRef.current
if (!composer || !dock) {
return
}
// Floating composer is out of the thread's flow — it must not reserve any
// bottom clearance. Zero the measured vars so the thread reclaims the space.
// Read through a ref so the callback stays stable, and read THIS surface's
// own state: pop-out is per layout zone, so a float in the left split must
// not zero the right split's clearance.
if (poppedOutRef.current) {
lastBucketedHeightRef.current = 0
lastBucketedSurfaceHeightRef.current = 0
setSurfaceVar(composer, COMPOSER_HEIGHT_VAR, '0px')
setSurfaceVar(composer, COMPOSER_SURFACE_HEIGHT_VAR, '0px')
return
}
const { height } = dock.getBoundingClientRect()
const { width } = composer.getBoundingClientRect()
const surfaceHeight = composerSurfaceRef.current?.getBoundingClientRect().height
if (width > 0) {
const nextFit = fitForWidth(width)
if (!sameFit(nextFit, lastFitRef.current)) {
lastFitRef.current = nextFit
setFit(nextFit)
}
}
// Expand once the input has actually wrapped past a single line. The
// observer only fires on real size changes, so this reads scrollHeight at
// most once per wrap (not per keystroke). One line ≈ 28px (1.625rem
// min-height + padding); a second line clears ~36px. We only ever expand
// here — collapse is handled by the emptied-draft effect to avoid
// oscillating across the wrap boundary as the input switches widths.
const editor = editorRef.current
if (editor && editor.scrollHeight > COMPOSER_SINGLE_LINE_MAX_PX) {
setExpanded(true)
}
if (height > 0) {
const bucket = Math.round(height / 8) * 8
if (bucket !== lastBucketedHeightRef.current) {
lastBucketedHeightRef.current = bucket
setSurfaceVar(composer, COMPOSER_HEIGHT_VAR, `${bucket}px`)
}
}
if (surfaceHeight && surfaceHeight > 0) {
const bucket = Math.round(surfaceHeight / 8) * 8
if (bucket !== lastBucketedSurfaceHeightRef.current) {
lastBucketedSurfaceHeightRef.current = bucket
setSurfaceVar(composer, COMPOSER_SURFACE_HEIGHT_VAR, `${bucket}px`)
}
}
}, [composerDockRef, composerRef, composerSurfaceRef, editorRef])
useResizeObserver(syncComposerMetrics, composerDockRef, composerRef, composerSurfaceRef, editorRef)
// Toggling pop-out changes whether the composer reserves thread clearance.
// The ResizeObserver may not fire (the box can keep the same box size), so
// re-sync explicitly: docked republishes the measured height, floating zeroes
// it so the thread reclaims the bottom space.
useEffect(() => {
syncComposerMetrics()
}, [poppedOut, syncComposerMetrics])
useEffect(() => {
// Resolve the owning surface while the composer is still attached; the
// unmount cleanup runs after React detached the node, where closest() can
// no longer find [data-chat-surface].
const root = chatSurfaceRoot(composerRef.current)
return () => {
clearSurfaceVar(root, COMPOSER_HEIGHT_VAR)
clearSurfaceVar(root, COMPOSER_SURFACE_HEIGHT_VAR)
}
}, [composerRef])
// Every decision comes from the composer's OWN measured width, never the
// viewport's. There used to be a `(max-width: 30rem)` media query in here as
// well, and it quietly outranked everything: any window under 480px stacked
// the row AND compacted the pill in the same instant, regardless of how much
// room the composer actually had. That collapsed the whole progressive ladder
// into one step for small windows — HUD mode is ~470px, so it never saw the
// ladder at all — and it disagreed with the measured breakpoints (320 to
// stack) by 160px. The ResizeObserver knows the real width; the viewport is
// not a proxy for it.
//
// The ladder is monotonic: each stage implies the ones above it, so the pill
// is always compact by the time the row stacks, and the voice controls are
// always folded before minimal drops them.
return {
compactPill: fit.compactPill || fit.tight,
foldVoice: fit.foldVoice || fit.minimal,
minimal: fit.minimal,
stacked: expanded || fit.tight,
tight: fit.tight
}
}
@@ -0,0 +1,61 @@
import { useEffect, useRef, useState } from 'react'
import { useI18n } from '@/i18n'
import { resetBrowseState } from '@/store/composer-input-history'
import { pickPlaceholder } from '../composer-utils'
interface UseComposerPlaceholderOptions {
disabled: boolean
reconnecting: boolean
sessionId: null | string | undefined
}
/**
* The composer's placeholder text. A resting starter (new session) / continuation
* (existing session) is picked once and only re-rolled when we genuinely move to
* a *different* conversation — the null→id persist of a freshly-started session
* keeps its starter so the text doesn't flip mid-stream. While the transport is
* down, it swaps to a reconnecting / starting message instead.
*/
export function useComposerPlaceholder({ disabled, reconnecting, sessionId }: UseComposerPlaceholderOptions): string {
const { t } = useI18n()
const newSessionPlaceholders = t.composer.newSessionPlaceholders
const followUpPlaceholders = t.composer.followUpPlaceholders
const [restingPlaceholder, setRestingPlaceholder] = useState(() =>
pickPlaceholder(sessionId ? followUpPlaceholders : newSessionPlaceholders)
)
const prevSessionIdRef = useRef(sessionId)
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
useEffect(() => {
const prev = prevSessionIdRef.current
prevSessionIdRef.current = sessionId
if (prev === sessionId) {
return
}
// null → id: the new session we're already in just got persisted. Keep the
// starter we showed instead of swapping to a follow-up under the user.
if (prev == null && sessionId) {
return
}
resetBrowseState(prev)
setRestingPlaceholder(pickPlaceholder(sessionId ? followUpPlaceholders : newSessionPlaceholders))
}, [followUpPlaceholders, newSessionPlaceholders, sessionId])
// When the transport is disabled it's because the gateway isn't open.
// Distinguish a cold start ("Starting Hermes...") from a dropped connection
// we're trying to restore. During reconnect, keep the textbox editable so a
// flaky network doesn't block drafting; only submit/backend actions stay
// disabled until the gateway is open again.
return disabled
? reconnecting
? t.composer.placeholderReconnecting
: t.composer.placeholderStarting
: restingPlaceholder
}
@@ -0,0 +1,45 @@
import { cleanup, render, screen } from '@testing-library/react'
import { useRef } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { $composerPopoutGesturesEnabled, $composerPopoutZones } from '@/store/composer-popout'
import { useComposerPopout } from './use-composer-popout'
vi.mock('@/components/pane-shell/pane-visibility', () => ({
usePaneGroup: () => 'test-zone',
usePaneVisible: () => true
}))
vi.mock('@/hooks/use-resize-observer', () => ({ useResizeObserver: () => undefined }))
vi.mock('@/store/windows', () => ({ isSecondaryWindow: () => false }))
function PopoutAffordanceHarness() {
const composerRef = useRef<HTMLFormElement>(null)
const { popoutAllowed } = useComposerPopout({ composerRef })
return (
<form ref={composerRef}>{popoutAllowed && <div data-slot="composer-drag-region" data-testid="drag-region" />}</form>
)
}
describe('useComposerPopout', () => {
beforeEach(() => {
$composerPopoutZones.set({})
$composerPopoutGesturesEnabled.set(true)
})
afterEach(() => {
cleanup()
$composerPopoutZones.set({})
$composerPopoutGesturesEnabled.set(true)
})
it('removes the pop-out affordance when gestures are disabled', () => {
$composerPopoutGesturesEnabled.set(false)
render(<PopoutAffordanceHarness />)
expect(screen.queryByTestId('drag-region')).toBeNull()
})
})
@@ -0,0 +1,171 @@
import { useStore } from '@nanostores/react'
import { type RefObject, useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { usePaneGroup, usePaneVisible } from '@/components/pane-shell/pane-visibility'
import { useResizeObserver } from '@/hooks/use-resize-observer'
import { triggerHaptic } from '@/lib/haptics'
import {
$composerPopoutGesturesEnabled,
$composerPopoutZone,
clampPopoutPosition,
getComposerPopoutZone,
popoutBoundsElement,
type PopoutPosition,
readPopoutBounds,
setComposerPoppedOut
} from '@/store/composer-popout'
import { isSecondaryWindow } from '@/store/windows'
import { useComposerPopoutGestures } from './use-popout-drag'
interface UseComposerPopoutOptions {
composerRef: RefObject<HTMLFormElement | null>
}
/**
* This surface's on-screen placement, derived from its zone's drag intent.
*
* A zone stores one intent for its whole tab stack — drag the box in any tab and
* it moves in all of them — but each surface owns a different rect, so the
* intent is clamped per surface. Clamping into the store instead would have
* every keep-alive-mounted tab overwrite the others with a position bounded by
* ITS geometry, last writer winning: that's how a drag in one tab used to get
* lost in another.
*
* Re-placing is skipped while this surface drags (the gesture already clamped
* against this rect) and while it's an inactive tab (still mounted, so a live
* drag would otherwise force a reflow per background tab per frame).
*/
function usePopoutPlacement(
composerRef: RefObject<HTMLFormElement | null>,
groupId: string,
intent: PopoutPosition,
dragging: boolean,
poppedOut: boolean
): PopoutPosition {
const [placement, setPlacement] = useState(intent)
const visible = usePaneVisible()
// Re-place while this surface is the visible tab and isn't itself dragging.
const live = poppedOut && visible && !dragging
// Resolved before the shared ResizeObserver below registers (hook order puts
// this layout effect first), so the observer always has this surface's own
// bounds element rather than a document-wide first match.
const boundsRef = useRef<Element | null>(null)
useLayoutEffect(() => {
boundsRef.current = popoutBoundsElement(composerRef.current)
})
const reclamp = useCallback(() => {
const el = composerRef.current
if (!el) {
return
}
const size = { height: el.offsetHeight, width: el.offsetWidth }
const next = clampPopoutPosition(getComposerPopoutZone(groupId).position, size, readPopoutBounds(el))
// Bail on an unchanged placement: a sash drag resizes the surface every
// frame, and a fresh object each time re-renders the whole composer.
setPlacement(prev => (prev.bottom === next.bottom && prev.right === next.right ? prev : next))
}, [composerRef, groupId])
// The surface resizing (sash drag, sidebar open, tab split) re-places the box
// against its new rect; the composer resizing (a growing draft) re-places it
// against its new height.
useResizeObserver(
useCallback(() => {
if (live) {
reclamp()
}
}, [live, reclamp]),
composerRef,
boundsRef
)
// useLayoutEffect, not useEffect: a tab revealed after the box was dragged in
// another one must not paint a frame at its stale placement before catching
// up. Runs before paint, and no-ops for hidden tabs (`live`).
useLayoutEffect(() => {
if (!live) {
return undefined
}
reclamp()
// A second pass after layout settles (sidebar widths, fonts): anyone
// restored out of bounds is pulled back even if the first measure was
// premature.
const raf = requestAnimationFrame(reclamp)
window.addEventListener('resize', reclamp)
return () => {
cancelAnimationFrame(raf)
window.removeEventListener('resize', reclamp)
}
}, [intent, live, reclamp])
return dragging ? intent : placement
}
/**
* Pop-out engine: the docked↔floating state, the dock/float/toggle actions, the
* drag gestures, and this surface's placement.
*
* State is scoped to the surface's layout ZONE (its tab stack): tabs in the same
* zone share one float, so switching tabs keeps the box exactly where you put
* it, while a split zone beside them keeps its own — popping out on the left
* doesn't fling a composer out of the right.
*
* Secondary windows (the tiny Ctrl+Shift+N window, subagent watch windows) stay
* docked: a floating composer makes no sense in a scratch window.
*/
export function useComposerPopout({ composerRef }: UseComposerPopoutOptions) {
const groupId = usePaneGroup()
const gesturesEnabled = useStore($composerPopoutGesturesEnabled)
const popoutAllowed = gesturesEnabled && !isSecondaryWindow()
const zone = useStore(useMemo(() => $composerPopoutZone(groupId), [groupId]))
const poppedOut = zone.poppedOut && popoutAllowed
const handleComposerPopOut = useCallback(() => {
triggerHaptic('open')
setComposerPoppedOut(groupId, true)
}, [groupId])
const handleComposerDock = useCallback(() => {
triggerHaptic('success')
setComposerPoppedOut(groupId, false)
}, [groupId])
// Double-click the grab area toggles dock/float. Undocking restores the last
// position (a zone's stored position is never cleared on dock).
const handleComposerToggle = useCallback(() => {
poppedOut ? handleComposerDock() : handleComposerPopOut()
}, [handleComposerDock, handleComposerPopOut, poppedOut])
const {
dockProximity,
dragging,
onPointerDown: onComposerGesturePointerDown
} = useComposerPopoutGestures({
composerRef,
groupId,
onDock: handleComposerDock,
onPopOut: handleComposerPopOut,
poppedOut,
position: zone.position
})
const popoutPosition = usePopoutPlacement(composerRef, groupId, zone.position, dragging, poppedOut)
return {
dockProximity,
dragging,
handleComposerToggle,
onComposerGesturePointerDown,
popoutAllowed,
popoutPosition,
poppedOut
}
}
@@ -0,0 +1,198 @@
import { act, cleanup, renderHook, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
$parkedQueueSessions,
$queuedPromptsBySession,
enqueueQueuedPrompt,
getQueuedPrompts,
isQueueParked,
parkQueuedPrompts
} from '@/store/composer-queue'
import type { QueueEditState } from '../composer-utils'
import type { ChatBarProps } from '../types'
import { useComposerQueue } from './use-composer-queue'
// The park ↔ drain contract at the hook level. The store tests pin the pure
// pieces (shouldAutoDrain, park bookkeeping); these pin the wiring — the
// auto-drain effect honoring the park, and send-now-while-busy lifting it so
// the settle drain still flows (the regression that sank the old blanket
// interrupt latch).
const SESSION_KEY = 'stored-session-queue-hook'
function renderQueueHook(overrides: { busy?: boolean; onCancel?: () => void; onSteer?: ChatBarProps['onSteer'] } = {}) {
const onSubmit = vi.fn<ChatBarProps['onSubmit']>(async () => true)
const onCancel = overrides.onCancel ?? vi.fn()
const onSteer = overrides.onSteer
const queueEditRef: { current: QueueEditState | null } = { current: null }
const hook = renderHook(
({ busy }: { busy: boolean }) =>
useComposerQueue({
activeQueueSessionKey: SESSION_KEY,
attachments: [],
busy,
clearDraft: () => undefined,
draftRef: { current: '' },
focusInput: () => undefined,
loadIntoComposer: () => undefined,
onCancel,
onSteer,
onSubmit,
queueEditRef,
queueSessionKey: SESSION_KEY,
sessionId: 'rt-session-queue-hook'
}),
{ initialProps: { busy: overrides.busy ?? false } }
)
return { hook, onCancel, onSubmit }
}
describe('useComposerQueue park integration', () => {
beforeEach(() => {
window.localStorage.clear()
$queuedPromptsBySession.set({})
$parkedQueueSessions.set({})
})
afterEach(() => {
cleanup()
vi.restoreAllMocks()
$queuedPromptsBySession.set({})
$parkedQueueSessions.set({})
})
it('auto-drains an unparked queue once idle', async () => {
enqueueQueuedPrompt(SESSION_KEY, { attachments: [], text: 'flows' })
const { onSubmit } = renderQueueHook()
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1))
expect(getQueuedPrompts(SESSION_KEY)).toHaveLength(0)
})
it('holds a parked queue at the idle settle (the Stop edge)', async () => {
enqueueQueuedPrompt(SESSION_KEY, { attachments: [], text: 'halted' })
parkQueuedPrompts(SESSION_KEY)
const { hook, onSubmit } = renderQueueHook({ busy: true })
// The Stop settle: busy flips false with the park in place.
hook.rerender({ busy: false })
await act(async () => {
await Promise.resolve()
})
expect(onSubmit).not.toHaveBeenCalled()
expect(getQueuedPrompts(SESSION_KEY)).toHaveLength(1)
})
it('drainNextQueued sends a parked entry and lifts the park (manual resume)', async () => {
enqueueQueuedPrompt(SESSION_KEY, { attachments: [], text: 'resumed' })
parkQueuedPrompts(SESSION_KEY)
const { hook, onSubmit } = renderQueueHook()
await act(async () => {
await hook.result.current.drainNextQueued()
})
expect(onSubmit).toHaveBeenCalledTimes(1)
expect(isQueueParked(SESSION_KEY)).toBe(false)
})
it('sendQueuedNow while busy unparks so the settle drain flows (no stale latch)', async () => {
const first = enqueueQueuedPrompt(SESSION_KEY, { attachments: [], text: 'first' })
enqueueQueuedPrompt(SESSION_KEY, { attachments: [], text: 'send me now' })
parkQueuedPrompts(SESSION_KEY)
const { hook, onCancel, onSubmit } = renderQueueHook({ busy: true })
const target = getQueuedPrompts(SESSION_KEY).find(e => e.id !== first!.id)!
act(() => {
hook.result.current.sendQueuedNow(target.id)
})
// The interrupt fired and the park lifted — this interrupt exists to reach
// the queue, not to halt it.
expect(onCancel).toHaveBeenCalledTimes(1)
expect(isQueueParked(SESSION_KEY)).toBe(false)
// Turn settles → the promoted entry drains.
hook.rerender({ busy: false })
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1))
expect(onSubmit.mock.calls[0]?.[0]).toBe('send me now')
})
it('steerQueuedNow delivers via onSteer without cancelling and removes the entry', async () => {
const entry = enqueueQueuedPrompt(SESSION_KEY, { attachments: [], text: 'steer me' })
const onSteer = vi.fn(async () => true)
const { hook, onCancel, onSubmit } = renderQueueHook({ busy: true, onSteer })
await act(async () => {
expect(await hook.result.current.steerQueuedNow(entry!.id)).toBe(true)
})
expect(onSteer).toHaveBeenCalledWith('steer me')
// A redirect rides the live turn: no interrupt, no submit.
expect(onCancel).not.toHaveBeenCalled()
expect(onSubmit).not.toHaveBeenCalled()
expect(getQueuedPrompts(SESSION_KEY)).toHaveLength(0)
})
it('a rejected steer leaves the entry queued so the settle drain still sends it', async () => {
const entry = enqueueQueuedPrompt(SESSION_KEY, { attachments: [], text: 'kept on reject' })
const onSteer = vi.fn(async () => false)
const { hook, onSubmit } = renderQueueHook({ busy: true, onSteer })
await act(async () => {
expect(await hook.result.current.steerQueuedNow(entry!.id)).toBe(false)
})
expect(getQueuedPrompts(SESSION_KEY)).toHaveLength(1)
// Turn settles → the surviving entry drains normally.
hook.rerender({ busy: false })
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1))
expect(onSubmit.mock.calls[0]?.[0]).toBe('kept on reject')
})
it('steerQueuedNow refuses unsteerable entries (slash commands execute, never steer)', async () => {
const slash = enqueueQueuedPrompt(SESSION_KEY, { attachments: [], text: '/compress' })
const onSteer = vi.fn(async () => true)
// Busy, but a slash command never steers. (Idle needs no case of its own:
// an idle session auto-drains its queue, so there is never an entry left
// to steer — asserting that here would just re-test auto-drain.)
const busy = renderQueueHook({ busy: true, onSteer })
await act(async () => {
expect(await busy.hook.result.current.steerQueuedNow(slash!.id)).toBe(false)
})
expect(onSteer).not.toHaveBeenCalled()
expect(getQueuedPrompts(SESSION_KEY)).toHaveLength(1)
})
it('a delivered steer lifts the park so the rest of the queue flows', async () => {
const steerable = enqueueQueuedPrompt(SESSION_KEY, { attachments: [], text: 'redirect' })
enqueueQueuedPrompt(SESSION_KEY, { attachments: [], text: 'follows after' })
parkQueuedPrompts(SESSION_KEY)
const onSteer = vi.fn(async () => true)
const { hook } = renderQueueHook({ busy: true, onSteer })
await act(async () => {
expect(await hook.result.current.steerQueuedNow(steerable!.id)).toBe(true)
})
expect(isQueueParked(SESSION_KEY)).toBe(false)
expect(getQueuedPrompts(SESSION_KEY)).toHaveLength(1)
})
})
@@ -0,0 +1,427 @@
import { useStore } from '@nanostores/react'
import { type RefObject, useCallback, useEffect, useRef, useState } from 'react'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { useSessionSlice } from '@/lib/use-session-slice'
import { type ComposerAttachment } from '@/store/composer'
import { resetBrowseState } from '@/store/composer-input-history'
import {
$parkedQueueSessions,
$queuedPromptsBySession,
enqueueQueuedPrompt,
getQueuedPrompts,
isSteerableEntry,
MAX_AUTO_DRAIN_ATTEMPTS,
migrateQueuedPrompts,
promoteQueuedPrompt,
type QueuedPromptEntry,
removeQueuedPrompt,
shouldAutoDrain,
unparkQueuedPrompts,
updateQueuedPrompt
} from '@/store/composer-queue'
import { notify } from '@/store/notifications'
import { cloneAttachments, type QueueEditState } from '../composer-utils'
import { useComposerScope } from '../scope'
import type { ChatBarProps } from '../types'
interface UseComposerQueueArgs {
activeQueueSessionKey: string | null
attachments: ComposerAttachment[]
busy: boolean
clearDraft: () => void
draftRef: RefObject<string>
focusInput: () => void
loadIntoComposer: (text: string, attachments: ComposerAttachment[]) => void
onCancel: ChatBarProps['onCancel']
onSteer: ChatBarProps['onSteer']
onSubmit: ChatBarProps['onSubmit']
queueEditRef: RefObject<QueueEditState | null>
queueSessionKey: ChatBarProps['queueSessionKey']
sessionId: string | null | undefined
}
/**
* The composer's queue engine — everything about queued turns: the per-session
* queue store binding, in-place queued-prompt editing (begin/step/exit), the
* shared drain lock + send-then-remove sequence, manual send-now, and the
* edge-independent auto-drain with bounded retries. It consumes the draft API
* (draftRef/clearDraft/loadIntoComposer/focusInput) and writes the
* coordinator-owned `queueEditRef` so the draft engine can read the edit state
* without a back-reference. Behaviour-identical to the inline original.
*/
export function useComposerQueue({
activeQueueSessionKey,
attachments,
busy,
clearDraft,
draftRef,
focusInput,
loadIntoComposer,
onCancel,
onSteer,
onSubmit,
queueEditRef,
queueSessionKey,
sessionId
}: UseComposerQueueArgs) {
const { t } = useI18n()
const scope = useComposerScope()
// Per-session slice (edge): re-renders only when THIS session's queue changes,
// not on cross-session queue churn (the plain atom's map ref changes on every
// write; the keyed array does not).
const queuedPrompts = useSessionSlice($queuedPromptsBySession, activeQueueSessionKey)
// Parked = the user explicitly halted this session (Stop/Esc) while prompts
// were queued. The map is tiny (only halted sessions) so a plain subscribe
// is fine; the auto-drain effect below reads it as a gate.
const parkedSessions = useStore($parkedQueueSessions)
const queueParked = Boolean(activeQueueSessionKey && parkedSessions[activeQueueSessionKey])
const [queueEdit, setQueueEdit] = useState<QueueEditState | null>(null)
queueEditRef.current = queueEdit
const setQueueEditSnapshot = useCallback(
(next: QueueEditState | null) => {
queueEditRef.current = next
setQueueEdit(next)
},
[queueEditRef]
)
const editingQueuedPrompt = queueEdit ? (queuedPrompts.find(entry => entry.id === queueEdit.entryId) ?? null) : null
const prevQueueKeyRef = useRef(activeQueueSessionKey)
const drainingQueueRef = useRef(false)
const drainFailuresRef = useRef(new Map<string, number>())
const beginQueuedEdit = (entry: QueuedPromptEntry) => {
if (!activeQueueSessionKey || queueEdit) {
return
}
setQueueEditSnapshot({
attachments: cloneAttachments(attachments),
draft: draftRef.current,
entryId: entry.id,
sessionKey: activeQueueSessionKey
})
// Edit what the panel SHOWS. A queued `/skill` entry's text is the
// expanded skill body — never drop that into the composer.
loadIntoComposer(entry.displayText ?? entry.text, entry.attachments)
triggerHaptic('selection')
focusInput()
}
// Walk queued entries while editing (ArrowUp = older, ArrowDown = newer),
// saving the in-progress edit on each step. Stepping newer past the last
// entry exits edit mode and restores the pre-edit draft.
const stepQueuedEdit = (direction: -1 | 1) => {
if (!queueEdit) {
return false
}
const index = queuedPrompts.findIndex(e => e.id === queueEdit.entryId)
const target = index + direction
if (index < 0 || target < 0) {
return index >= 0 // at the oldest: swallow; missing entry: let it fall through
}
const saved = updateQueuedPrompt(queueEdit.sessionKey, queueEdit.entryId, {
attachments: cloneAttachments(attachments),
text: draftRef.current
})
const next = queuedPrompts[target]
if (next) {
setQueueEditSnapshot({ ...queueEdit, entryId: next.id })
loadIntoComposer(next.displayText ?? next.text, next.attachments)
} else {
setQueueEditSnapshot(null)
loadIntoComposer(queueEdit.draft, queueEdit.attachments)
}
triggerHaptic(saved ? 'success' : 'selection')
focusInput()
return true
}
const exitQueuedEdit = (action: 'cancel' | 'save'): boolean => {
if (!queueEdit) {
return false
}
if (action === 'save') {
const text = draftRef.current
const next = cloneAttachments(attachments)
if (!text.trim() && next.length === 0) {
return false
}
const saved = updateQueuedPrompt(queueEdit.sessionKey, queueEdit.entryId, { attachments: next, text })
triggerHaptic(saved ? 'success' : 'selection')
} else {
triggerHaptic('cancel')
}
setQueueEditSnapshot(null)
loadIntoComposer(queueEdit.draft, queueEdit.attachments)
focusInput()
return true
}
const queueCurrentDraft = useCallback(() => {
const text = draftRef.current
if (!activeQueueSessionKey || (!text.trim() && attachments.length === 0)) {
return false
}
if (!enqueueQueuedPrompt(activeQueueSessionKey, { text, attachments })) {
return false
}
clearDraft()
scope.attachments.clear()
triggerHaptic('selection')
return true
}, [activeQueueSessionKey, attachments, clearDraft, draftRef, scope.attachments])
// All queue drain paths share one lock + send-then-remove sequence.
// `pickEntry` lets each caller choose head, by-id, or skip-edited.
const runDrain = useCallback(
async (pickEntry: (entries: QueuedPromptEntry[]) => QueuedPromptEntry | undefined): Promise<boolean> => {
if (drainingQueueRef.current || !activeQueueSessionKey) {
return false
}
const drainQueueSessionKey = activeQueueSessionKey
const drainRuntimeSessionId = sessionId ?? null
const entry = pickEntry(getQueuedPrompts(drainQueueSessionKey))
if (!entry) {
return false
}
drainingQueueRef.current = true
try {
const accepted = await Promise.resolve(
onSubmit(entry.text, {
attachments: entry.attachments,
...(entry.displayText ? { displayText: entry.displayText } : {}),
fromQueue: true,
sessionId: drainRuntimeSessionId,
storedSessionId: drainQueueSessionKey
})
)
if (accepted === false) {
return false
}
drainFailuresRef.current.delete(entry.id)
removeQueuedPrompt(drainQueueSessionKey, entry.id)
resetBrowseState(drainRuntimeSessionId)
// A successful drain means the queue is flowing again — lift any park
// so the remaining entries follow. Manual drains (Enter on an empty
// composer, the per-row send arrow) are exactly the resume gestures a
// parked queue waits for; the auto path only reaches here unparked.
unparkQueuedPrompts(drainQueueSessionKey)
return true
} finally {
drainingQueueRef.current = false
}
},
[activeQueueSessionKey, onSubmit, sessionId]
)
const pickDrainHead = useCallback(
(entries: QueuedPromptEntry[]) => {
const skip = queueEditRef.current?.entryId
return skip ? entries.find(e => e.id !== skip) : entries[0]
},
[queueEditRef] // reads the edit id off a ref so the lock-holder always sees the latest
)
const drainNextQueued = useCallback(() => runDrain(pickDrainHead), [pickDrainHead, runDrain])
const sendQueuedNow = useCallback(
(id: string) => {
if (!activeQueueSessionKey || id === queueEdit?.entryId) {
return false
}
if (busy) {
// Promote to the head, then interrupt. The gateway always emits a
// settle (message.complete + session.info running:false) when the
// turn unwinds, and the busy→false auto-drain below sends this entry.
// Unpark first: this interrupt exists to REACH the queue, so the
// settle drain must flow — unlike a Stop/Esc halt, which parks.
promoteQueuedPrompt(activeQueueSessionKey, id)
unparkQueuedPrompts(activeQueueSessionKey)
triggerHaptic('selection')
void Promise.resolve(onCancel())
return true
}
// A manual send clears the auto-drain backoff so a stuck entry the user
// taps gets a fresh attempt (and re-enables auto-retry on success).
drainFailuresRef.current.delete(id)
return runDrain(entries => entries.find(e => e.id === id))
},
[activeQueueSessionKey, busy, onCancel, queueEdit, runDrain]
)
// Deliver a queued entry as a mid-turn redirect — the queue-panel sibling of
// the composer's steer-on-Enter. No interrupt, no drain lock: a redirect
// rides the live turn (the gateway either restarts the active request with
// its displayed context or waits for the current tool boundary), so the turn
// keeps flowing and the remaining queue is untouched. Only meaningful while
// busy — idle has no turn to redirect, and `sendQueuedNow` already covers it.
const steerQueuedNow = useCallback(
async (id: string): Promise<boolean> => {
if (!onSteer || !busy || !activeQueueSessionKey || id === queueEditRef.current?.entryId) {
return false
}
const entry = getQueuedPrompts(activeQueueSessionKey).find(e => e.id === id)
if (!entry || !isSteerableEntry(entry)) {
return false
}
triggerHaptic('submit')
const accepted = await Promise.resolve(onSteer(entry.text))
// Rejected (turn already settling, gateway said no): leave the entry
// queued exactly where it was — the settle drain picks it up, so the
// words are never lost. Only a delivered redirect consumes the entry.
if (!accepted) {
return false
}
drainFailuresRef.current.delete(id)
removeQueuedPrompt(activeQueueSessionKey, id)
// A steer is the same "keep it moving" intent as a manual send — a park
// from an earlier Stop must not hold back what's left of the queue.
unparkQueuedPrompts(activeQueueSessionKey)
return true
},
[activeQueueSessionKey, busy, onSteer, queueEditRef]
)
// Edge-independent auto-drain: send the head whenever the session is idle and
// the queue is non-empty, bounding retries so a thrown/rejected onSubmit (e.g.
// a stale-session 404) can't strand the entry permanently nor spin-loop. The
// drain lock serializes sends; a remount/reconnect resets the failure counts.
const autoDrainNext = useCallback(() => {
if (busy || queueParked || drainingQueueRef.current || !activeQueueSessionKey) {
return
}
const entry = pickDrainHead(queuedPrompts)
if (!entry || (drainFailuresRef.current.get(entry.id) ?? 0) >= MAX_AUTO_DRAIN_ATTEMPTS) {
return
}
const onFail = () => {
const fails = (drainFailuresRef.current.get(entry.id) ?? 0) + 1
drainFailuresRef.current.set(entry.id, fails)
if (fails >= MAX_AUTO_DRAIN_ATTEMPTS) {
notify({
id: 'composer-queue-stuck',
kind: 'error',
title: t.composer.queueStuckTitle,
message: t.composer.queueStuckBody
})
}
}
void runDrain(() => entry)
.then(sent => {
if (!sent) {
onFail()
}
})
.catch(onFail)
}, [activeQueueSessionKey, busy, pickDrainHead, queueParked, queuedPrompts, runDrain, t])
// Re-key on a runtime session-id change. A stable stored id (queueSessionKey)
// never churns, so a change there is a real session switch and must NOT
// migrate; only the runtime-derived key (queueSessionKey falsy → key is
// sessionId) churns on a backend bounce/resume of the same conversation.
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
useEffect(() => {
const prev = prevQueueKeyRef.current
prevQueueKeyRef.current = activeQueueSessionKey
if (queueSessionKey || !prev || !activeQueueSessionKey || prev === activeQueueSessionKey) {
return
}
migrateQueuedPrompts(prev, activeQueueSessionKey)
}, [activeQueueSessionKey, queueSessionKey])
// Queued turns flow whenever the session is idle — on the busy→false settle
// edge, on mount/reconnect, and after a re-key — so a swallowed edge can't
// strand them. A park (explicit Stop/Esc) is the one gate: those entries wait
// for the user. To cancel queued turns, the user deletes them from the panel.
useEffect(() => {
if (shouldAutoDrain({ isBusy: busy, parked: queueParked, queueLength: queuedPrompts.length })) {
autoDrainNext()
}
}, [autoDrainNext, busy, queueParked, queuedPrompts.length])
// Queue-edit cleanup: on session swap the scope effect already stashed the
// edit snapshot; only restore into the composer when still on the same scope.
useEffect(() => {
if (!queueEdit) {
return
}
if (queueEdit.sessionKey === activeQueueSessionKey) {
if (editingQueuedPrompt) {
return
}
setQueueEditSnapshot(null)
loadIntoComposer(queueEdit.draft, queueEdit.attachments)
return
}
setQueueEditSnapshot(null)
}, [activeQueueSessionKey, editingQueuedPrompt, queueEdit, setQueueEditSnapshot]) // eslint-disable-line react-hooks/exhaustive-deps
return {
beginQueuedEdit,
drainNextQueued,
editingQueuedPrompt,
exitQueuedEdit,
queueCurrentDraft,
queueEdit,
queueParked,
queuedPrompts,
sendQueuedNow,
steerQueuedNow,
stepQueuedEdit
}
}
@@ -0,0 +1,564 @@
import { act, cleanup, renderHook, waitFor } from '@testing-library/react'
import { type Dispatch, type PropsWithChildren, type SetStateAction, useLayoutEffect, useState } from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { PaneVisibleContext } from '@/components/pane-shell/pane-visibility'
import { $clarifyRequests } from '@/store/clarify'
import type { ComposerAttachment } from '@/store/composer'
import { $gateway } from '@/store/gateway'
import {
clearAllPrompts,
hasBlockingPromptRequest,
setApprovalRequest,
setSecretRequest,
setSudoRequest
} from '@/store/prompts'
import { type ComposerTarget, requestComposerSubmit } from '../focus'
import { ComposerScopeProvider, ComposerSurfaceProvider, MAIN_COMPOSER_SCOPE } from '../scope'
import { useComposerSubmit } from './use-composer-submit'
interface SubmitHarnessOptions {
attachments?: ComposerAttachment[]
busy?: boolean
compacting?: boolean
inputDisabled?: boolean
scopeTarget?: ComposerTarget
sessionKey?: string | null
submitOnHide?: boolean
surfaceId?: string | null
text?: string
visible?: boolean
}
let surfaceSequence = 0
function renderSubmitHook({
attachments = [],
busy = false,
compacting = false,
inputDisabled = false,
scopeTarget = 'main',
sessionKey = 'stored-session',
submitOnHide = false,
surfaceId,
text = '',
visible = true
}: SubmitHarnessOptions = {}) {
const resolvedSurfaceId = surfaceId === undefined ? `test-surface-${++surfaceSequence}` : surfaceId
const draftRef = { current: text }
const editor = window.document.createElement('div')
editor.dataset.slot = 'composer-rich-input'
editor.textContent = text
const editorRef = { current: editor }
const onCancel = vi.fn()
const onSteer = vi.fn(async () => true)
const onSubmit = vi.fn(async () => true)
const queueCurrentDraft = vi.fn(() => true)
let updatePaneVisible: Dispatch<SetStateAction<boolean>> | undefined
const clearDraft = vi.fn(() => {
draftRef.current = ''
editorRef.current!.textContent = ''
})
const Wrapper = ({ children }: PropsWithChildren) => {
const [paneVisible, setPaneVisible] = useState(visible)
updatePaneVisible = setPaneVisible
useLayoutEffect(() => {
if (submitOnHide && !paneVisible) {
requestComposerSubmit('ship while hiding', { target: scopeTarget })
}
}, [paneVisible])
return (
<ComposerScopeProvider value={{ ...MAIN_COMPOSER_SCOPE, target: scopeTarget }}>
<ComposerSurfaceProvider value={resolvedSurfaceId}>
<PaneVisibleContext.Provider value={paneVisible}>
<div
data-composer-surface-id={resolvedSurfaceId ?? undefined}
data-composer-target={scopeTarget}
data-pane-hidden={paneVisible ? undefined : ''}
>
{children}
</div>
</PaneVisibleContext.Provider>
</ComposerSurfaceProvider>
</ComposerScopeProvider>
)
}
const hook = renderHook(
() =>
useComposerSubmit({
activeQueueSessionKey: sessionKey,
activeQueueSessionKeyRef: { current: sessionKey },
attachments,
busy,
compacting,
clearDraft,
disabled: false,
draftRef,
drainNextQueued: vi.fn(async () => false),
editorRef,
exitQueuedEdit: vi.fn(() => false),
focusInput: vi.fn(),
inputDisabled,
loadIntoComposer: vi.fn(),
onCancel,
onSteer,
onSubmit,
queueCurrentDraft,
queueEdit: null,
queuedPrompts: [],
sessionId: 'runtime-session',
setComposerText: vi.fn(),
stashAt: vi.fn()
}),
{ wrapper: Wrapper }
)
return {
clearDraft,
hook,
onCancel,
onSteer,
onSubmit,
queueCurrentDraft,
composerSurfaceId: resolvedSurfaceId,
setPaneVisible(nextVisible: boolean) {
if (!updatePaneVisible) {
throw new Error('Pane visibility setter was not initialized')
}
updatePaneVisible(nextVisible)
}
}
}
describe('useComposerSubmit external request routing', () => {
afterEach(() => {
cleanup()
vi.restoreAllMocks()
})
it('does not fan out a main ship across keep-alives or other projects', async () => {
const visibleMain = renderSubmitHook({ sessionKey: 'session-a' })
const hiddenMain = renderSubmitHook({ sessionKey: 'session-b', visible: false })
const visibleTile = renderSubmitHook({ scopeTarget: 'tile:project-b', sessionKey: 'tile-session' })
const hiddenTile = renderSubmitHook({
scopeTarget: 'tile:project-c',
sessionKey: 'other-tile',
visible: false
})
expect(requestComposerSubmit('ship this branch', { target: 'main' })).toBe(true)
await waitFor(() =>
expect(visibleMain.onSubmit).toHaveBeenCalledWith('ship this branch', {
composerScope: 'session-a'
})
)
expect(visibleMain.onSubmit).toHaveBeenCalledTimes(1)
expect(hiddenMain.onSubmit).not.toHaveBeenCalled()
expect(visibleTile.onSubmit).not.toHaveBeenCalled()
expect(hiddenTile.onSubmit).not.toHaveBeenCalled()
})
it('routes a tile-targeted submit to that tile only', async () => {
const main = renderSubmitHook({ sessionKey: 'main-session' })
const tile = renderSubmitHook({ scopeTarget: 'tile:project-b', sessionKey: 'tile-session' })
expect(requestComposerSubmit('ship project B', { target: 'tile:project-b' })).toBe(true)
await waitFor(() =>
expect(tile.onSubmit).toHaveBeenCalledWith('ship project B', {
composerScope: 'tile-session'
})
)
expect(main.onSubmit).not.toHaveBeenCalled()
})
it('uses the captured surface id when two visible composers share a target', async () => {
const first = renderSubmitHook({ sessionKey: 'session-first' })
const second = renderSubmitHook({ sessionKey: 'session-second' })
requestComposerSubmit('ship exactly one session', { surfaceId: second.composerSurfaceId, target: 'main' })
await waitFor(() =>
expect(second.onSubmit).toHaveBeenCalledWith('ship exactly one session', {
composerScope: 'session-second'
})
)
expect(first.onSubmit).not.toHaveBeenCalled()
})
it('submits to the session visible at click time even when the same click switches tabs', async () => {
const hiddenA = renderSubmitHook({ sessionKey: 'session-a', visible: false })
const visibleB = renderSubmitHook({ sessionKey: 'session-b' })
act(() => {
requestComposerSubmit('ship session B', { target: 'main' })
visibleB.setPaneVisible(false)
hiddenA.setPaneVisible(true)
})
await waitFor(() =>
expect(visibleB.onSubmit).toHaveBeenCalledWith('ship session B', {
composerScope: 'session-b'
})
)
expect(hiddenA.onSubmit).not.toHaveBeenCalled()
})
it('does not fan out when visible composers do not have queue session keys yet', async () => {
const firstNewSession = renderSubmitHook({ sessionKey: null })
const secondNewSession = renderSubmitHook({ sessionKey: null })
act(() => {
requestComposerSubmit('ship the visible new session', { target: 'main' })
})
await waitFor(() => expect(firstNewSession.onSubmit).toHaveBeenCalledTimes(1))
expect(secondNewSession.onSubmit).not.toHaveBeenCalled()
})
it('fails closed when the visible composer has no surface identity', () => {
const unidentified = renderSubmitHook({ surfaceId: null })
expect(requestComposerSubmit('do not broadcast this', { target: 'main' })).toBe(false)
expect(unidentified.onSubmit).not.toHaveBeenCalled()
})
it('fails closed when a pinned origin surface is no longer visible', () => {
const hidden = renderSubmitHook({ sessionKey: 'hidden-origin', visible: false })
const visible = renderSubmitHook({ sessionKey: 'other-visible' })
expect(
requestComposerSubmit('do not send to a stale origin', {
surfaceId: hidden.composerSurfaceId,
target: 'main'
})
).toBe(false)
expect(hidden.onSubmit).not.toHaveBeenCalled()
expect(visible.onSubmit).not.toHaveBeenCalled()
})
it('does not submit through a composer whose pane is hidden during the request', () => {
const main = renderSubmitHook({ submitOnHide: true })
act(() => main.setPaneVisible(false))
expect(main.onSubmit).not.toHaveBeenCalled()
})
it('does not submit through a disabled composer', () => {
const disabled = renderSubmitHook({ inputDisabled: true })
requestComposerSubmit('do not send this', { target: 'main' })
expect(disabled.onSubmit).not.toHaveBeenCalled()
})
})
describe('useComposerSubmit busy-turn routing', () => {
afterEach(() => {
cleanup()
vi.restoreAllMocks()
})
it('treats a payload mid-turn as send (steer), not stop', async () => {
const { hook, onCancel, onSteer, onSubmit, queueCurrentDraft } = renderSubmitHook({
busy: true,
text: 'change course'
})
act(() => {
hook.result.current.submitDraft()
})
await waitFor(() => expect(onSteer).toHaveBeenCalledWith('change course'))
expect(queueCurrentDraft).not.toHaveBeenCalled()
expect(onCancel).not.toHaveBeenCalled()
expect(onSubmit).not.toHaveBeenCalled()
})
it('queues a plain-text follow-up while the active turn is compacting', () => {
const { hook, onCancel, onSteer, onSubmit, queueCurrentDraft } = renderSubmitHook({
busy: true,
compacting: true,
text: 'wait for the summary'
})
act(() => {
hook.result.current.submitDraft()
})
expect(queueCurrentDraft).toHaveBeenCalledTimes(1)
expect(onSteer).not.toHaveBeenCalled()
expect(onSubmit).not.toHaveBeenCalled()
expect(onCancel).not.toHaveBeenCalled()
})
it('runs slash commands immediately while busy', async () => {
const { clearDraft, hook, onCancel, onSteer, onSubmit, queueCurrentDraft } = renderSubmitHook({
busy: true,
text: '/compress preserve context'
})
act(() => {
hook.result.current.submitDraft()
})
await waitFor(() =>
expect(onSubmit).toHaveBeenCalledWith('/compress preserve context', { composerScope: 'stored-session' })
)
expect(clearDraft).toHaveBeenCalledTimes(1)
expect(onSteer).not.toHaveBeenCalled()
expect(queueCurrentDraft).not.toHaveBeenCalled()
expect(onCancel).not.toHaveBeenCalled()
})
it('queues an attachment-bearing follow-up while busy', () => {
const attachment: ComposerAttachment = { id: 'doc', kind: 'file', label: 'notes.txt' }
const { hook, onCancel, onSteer, onSubmit, queueCurrentDraft } = renderSubmitHook({
attachments: [attachment],
busy: true,
text: 'read this'
})
act(() => {
hook.result.current.submitDraft()
})
expect(queueCurrentDraft).toHaveBeenCalledTimes(1)
expect(onSteer).not.toHaveBeenCalled()
expect(onSubmit).not.toHaveBeenCalled()
expect(onCancel).not.toHaveBeenCalled()
})
it('stops an active turn only with an empty composer', () => {
const { hook, onCancel, onSteer, onSubmit, queueCurrentDraft } = renderSubmitHook({ busy: true })
act(() => {
hook.result.current.submitDraft()
})
expect(onCancel).toHaveBeenCalledTimes(1)
expect(onSteer).not.toHaveBeenCalled()
expect(onSubmit).not.toHaveBeenCalled()
expect(queueCurrentDraft).not.toHaveBeenCalled()
})
it('submits a normal turn while idle', async () => {
const { hook, onCancel, onSteer, onSubmit, queueCurrentDraft } = renderSubmitHook({ text: 'ordinary question' })
act(() => {
hook.result.current.submitDraft()
})
await waitFor(() =>
expect(onSubmit).toHaveBeenCalledWith('ordinary question', {
attachments: [],
composerScope: 'stored-session'
})
)
expect(onSteer).not.toHaveBeenCalled()
expect(queueCurrentDraft).not.toHaveBeenCalled()
expect(onCancel).not.toHaveBeenCalled()
})
it('threads the loaded composer scope through onSubmit for the #59305 submit-time guard', async () => {
const { hook, onSubmit } = renderSubmitHook({ text: 'hello' })
act(() => {
hook.result.current.submitDraft()
})
await waitFor(() =>
expect(onSubmit).toHaveBeenCalledWith('hello', expect.objectContaining({ composerScope: 'stored-session' }))
)
})
})
describe('useComposerSubmit with a clarify parked on the session', () => {
const gatewayRequest = vi.fn(async () => ({ ok: true }))
const parkClarify = (sessionId: string) => {
$clarifyRequests.set({
[sessionId]: {
requestId: `req-${sessionId}`,
question: 'which one?',
choices: ['a', 'b'],
multiSelect: false,
sessionId
}
})
$gateway.set({ request: gatewayRequest } as unknown as ReturnType<typeof $gateway.get>)
}
afterEach(() => {
cleanup()
gatewayRequest.mockClear()
$clarifyRequests.set({})
$gateway.set(null)
vi.restoreAllMocks()
})
it('skips the question and still sends the typed message on an idle session', async () => {
parkClarify('runtime-session')
const { hook, onSubmit } = renderSubmitHook({ text: 'actually do this instead' })
act(() => {
hook.result.current.submitDraft()
})
await waitFor(() =>
expect(gatewayRequest).toHaveBeenCalledWith('clarify.respond', {
request_id: 'req-runtime-session',
answer: ''
})
)
await waitFor(() =>
expect(onSubmit).toHaveBeenCalledWith('actually do this instead', expect.objectContaining({ attachments: [] }))
)
expect($clarifyRequests.get()['runtime-session']).toBeUndefined()
})
it('skips the question before steering a busy turn', async () => {
parkClarify('runtime-session')
const { hook, onSteer } = renderSubmitHook({ busy: true, text: 'change course' })
act(() => {
hook.result.current.submitDraft()
})
await waitFor(() => expect(onSteer).toHaveBeenCalledWith('change course'))
expect(gatewayRequest).toHaveBeenCalledWith('clarify.respond', { request_id: 'req-runtime-session', answer: '' })
})
it('leaves the question alone for an empty Enter (Stop, not an answer)', () => {
parkClarify('runtime-session')
const { hook, onCancel } = renderSubmitHook({ busy: true })
act(() => {
hook.result.current.submitDraft()
})
expect(gatewayRequest).not.toHaveBeenCalled()
expect($clarifyRequests.get()['runtime-session']).toBeDefined()
expect(onCancel).toHaveBeenCalledTimes(1)
})
it("leaves another session's question alone", async () => {
parkClarify('other-session')
const { hook, onSubmit } = renderSubmitHook({ text: 'unrelated message' })
act(() => {
hook.result.current.submitDraft()
})
await waitFor(() => expect(onSubmit).toHaveBeenCalled())
expect(gatewayRequest).not.toHaveBeenCalled()
expect($clarifyRequests.get()['other-session']).toBeDefined()
})
})
describe('useComposerSubmit with a blocking prompt parked on the session', () => {
// Typing cannot answer approval/sudo/secret prompts, so the busy submit must
// route text to the QUEUE — a steer would sit undelivered behind the blocked
// tool batch, and interrupting to force it through resolves the prompt empty
// and ends the turn as "Operation interrupted." with the message lost.
afterEach(() => {
cleanup()
clearAllPrompts()
vi.restoreAllMocks()
})
it('queues a busy text follow-up instead of steering while an approval is pending', () => {
setApprovalRequest({ command: 'rm -rf /tmp/x', description: 'dangerous', sessionId: 'runtime-session' })
const { hook, onCancel, onSteer, queueCurrentDraft } = renderSubmitHook({
busy: true,
text: 'and also fix the padding'
})
act(() => {
hook.result.current.submitDraft()
})
expect(queueCurrentDraft).toHaveBeenCalledTimes(1)
expect(onSteer).not.toHaveBeenCalled()
expect(onCancel).not.toHaveBeenCalled()
})
it('queues while a sudo prompt is pending', () => {
setSudoRequest({ requestId: 'sudo-1', sessionId: 'runtime-session' })
const { hook, onSteer, queueCurrentDraft } = renderSubmitHook({ busy: true, text: 'next thing' })
act(() => {
hook.result.current.submitDraft()
})
expect(queueCurrentDraft).toHaveBeenCalledTimes(1)
expect(onSteer).not.toHaveBeenCalled()
})
it('queues while a secret prompt is pending', () => {
setSecretRequest({ envVar: 'API_KEY', prompt: 'key?', requestId: 'sec-1', sessionId: 'runtime-session' })
const { hook, onSteer, queueCurrentDraft } = renderSubmitHook({ busy: true, text: 'next thing' })
act(() => {
hook.result.current.submitDraft()
})
expect(queueCurrentDraft).toHaveBeenCalledTimes(1)
expect(onSteer).not.toHaveBeenCalled()
})
it('still runs slash commands inline', async () => {
setApprovalRequest({ command: 'rm -rf /tmp/x', description: 'dangerous', sessionId: 'runtime-session' })
const { hook, onSteer, onSubmit, queueCurrentDraft } = renderSubmitHook({ busy: true, text: '/status' })
act(() => {
hook.result.current.submitDraft()
})
await waitFor(() => expect(onSubmit).toHaveBeenCalledWith('/status', { composerScope: 'stored-session' }))
expect(queueCurrentDraft).not.toHaveBeenCalled()
expect(onSteer).not.toHaveBeenCalled()
})
it("ignores another session's blocking prompt and still steers", async () => {
setApprovalRequest({ command: 'ls', description: 'other', sessionId: 'other-session' })
const { hook, onSteer, queueCurrentDraft } = renderSubmitHook({ busy: true, text: 'change course' })
act(() => {
hook.result.current.submitDraft()
})
await waitFor(() => expect(onSteer).toHaveBeenCalledWith('change course'))
expect(queueCurrentDraft).not.toHaveBeenCalled()
})
it('leaves the prompt pending — queueing must not resolve or dismiss it', () => {
setApprovalRequest({ command: 'rm -rf /tmp/x', description: 'dangerous', sessionId: 'runtime-session' })
const { hook } = renderSubmitHook({ busy: true, text: 'follow-up' })
act(() => {
hook.result.current.submitDraft()
})
// The approval card is still the turn's owner; only its own buttons answer it.
expect(hasBlockingPromptRequest('runtime-session')).toBe(true)
})
})
@@ -0,0 +1,268 @@
import { type RefObject, useLayoutEffect, useRef } from 'react'
import { usePaneVisible } from '@/components/pane-shell/pane-visibility'
import { SLASH_COMMAND_RE } from '@/lib/chat-runtime'
import { triggerHaptic } from '@/lib/haptics'
import { hasClarifyRequest, skipClarifyRequest } from '@/store/clarify'
import { clearSessionDraft, type ComposerAttachment } from '@/store/composer'
import { resetBrowseState } from '@/store/composer-input-history'
import { enqueueQueuedPrompt, type QueuedPromptEntry } from '@/store/composer-queue'
import { hasMcpSetupRequest, skipMcpSetupRequest } from '@/store/mcp-setup'
import { hasBlockingPromptRequest } from '@/store/prompts'
import { cloneAttachments, type QueueEditState } from '../composer-utils'
import { onComposerSubmitRequest } from '../focus'
import { pathifyRefs } from '../path-refs'
import { composerPlainText } from '../rich-editor'
import { useComposerScope, useComposerSurfaceId } from '../scope'
import type { ChatBarProps } from '../types'
interface UseComposerSubmitArgs {
activeQueueSessionKey: string | null
activeQueueSessionKeyRef: RefObject<string | null>
attachments: ComposerAttachment[]
busy: boolean
compacting: boolean
clearDraft: () => void
disabled: boolean
draftRef: RefObject<string>
drainNextQueued: () => Promise<boolean>
editorRef: RefObject<HTMLDivElement | null>
exitQueuedEdit: (action: 'cancel' | 'save') => boolean
focusInput: () => void
inputDisabled: boolean
loadIntoComposer: (text: string, attachments: ComposerAttachment[]) => void
onCancel: ChatBarProps['onCancel']
onSteer: ChatBarProps['onSteer']
onSubmit: ChatBarProps['onSubmit']
queueCurrentDraft: () => boolean
queueEdit: QueueEditState | null
queuedPrompts: QueuedPromptEntry[]
sessionId: string | null | undefined
setComposerText: (value: string) => void
stashAt: (scope: string | null, text?: string, attachments?: ComposerAttachment[]) => void
}
/**
* The composer's submit engine — the orchestration seam where the draft and
* queue meet. `submitDraft` is the one decision tree (queue-edit save · slash-
* now-while-busy · queue · drain · send · stop); `dispatchSubmit` is the shared
* send-with-restore primitive (re-loads + re-stashes the draft if the gateway
* rejects, so nothing is ever lost); `steerDraft` redirects the live turn. Reads
* the draft + queue APIs; owns no state of its own beyond the stable
* external-submit listener ref.
*/
export function useComposerSubmit({
activeQueueSessionKey,
activeQueueSessionKeyRef,
attachments,
busy,
compacting,
clearDraft,
disabled,
draftRef,
drainNextQueued,
editorRef,
exitQueuedEdit,
focusInput,
inputDisabled,
loadIntoComposer,
onCancel,
onSteer,
onSubmit,
queueCurrentDraft,
queueEdit,
queuedPrompts,
sessionId,
setComposerText,
stashAt
}: UseComposerSubmitArgs) {
const paneVisible = usePaneVisible()
const scope = useComposerScope()
const surfaceId = useComposerSurfaceId()
// Shared send primitive: fire onSubmit, and if the gateway rejects (accepted
// === false) or throws, re-load + re-stash the draft so the words survive.
const dispatchSubmit = (text: string, attachments?: ComposerAttachment[], displayKind?: 'hidden') => {
const submittedScope = activeQueueSessionKeyRef.current
const submittedAttachments = attachments ?? []
const restore = () => {
loadIntoComposer(text, submittedAttachments)
// Use the scope captured at dispatch, not whatever session is focused
// now — the gateway can reject well after the user has switched away,
// and re-stashing into the currently-focused session would overwrite
// its draft with the rejected text from a different session (#54527).
stashAt(submittedScope, text, submittedAttachments)
}
void Promise.resolve(
attachments
? onSubmit(text, { attachments, composerScope: submittedScope, ...(displayKind ? { displayKind } : {}) })
: onSubmit(text, { composerScope: submittedScope, ...(displayKind ? { displayKind } : {}) })
)
.then(accepted => void (accepted === false ? restore() : clearSessionDraft(submittedScope)))
.catch(restore)
}
// External "submit this prompt" requests (e.g. the review pane's agent-ship
// button) route through the same send path. Match both the composer target
// and the exact visible surface captured at click time — every tile stays
// mounted, and a session can be rendered in more than one pane.
const dispatchSubmitRef = useRef(dispatchSubmit)
dispatchSubmitRef.current = dispatchSubmit
useLayoutEffect(
() =>
onComposerSubmitRequest(({ surfaceId: requestedSurfaceId, target, text, displayKind }) => {
if (
target === scope.target &&
surfaceId !== null &&
requestedSurfaceId === surfaceId &&
paneVisible &&
!inputDisabled
) {
dispatchSubmitRef.current(text, undefined, displayKind)
}
}),
[inputDisabled, paneVisible, scope.target, surfaceId]
)
const submitDraft = () => {
if (disabled) {
return
}
// Source the text from the DOM editor, not React state. The AUI composer
// state (`draft`) and the derived `hasComposerPayload` lag the DOM by a
// render, so on fast typing or IME composition the final keystroke(s) may
// not have synced yet — reading state here drops the message (Enter looks
// like it does nothing; typing a trailing space only "fixes" it because the
// extra input event forces a state sync). draftRef is updated on every
// input event; refresh it from the editor once more to also cover an
// in-flight keystroke that hasn't fired its input event yet.
const editor = editorRef.current
if (editor) {
const domText = composerPlainText(editor)
if (domText !== draftRef.current) {
draftRef.current = domText
setComposerText(domText)
}
}
// A path that never got its committing space (`@apps/desktop/` left by a Tab
// descend, then Enter) is still the reference the user picked — promote it
// on the way out so it attaches instead of submitting as inert text.
const text = pathifyRefs(draftRef.current)
const payloadPresent = text.trim().length > 0 || attachments.length > 0
// A clarify card parked on this session owns the turn: the agent is blocked
// inside its tool batch waiting on `clarify.respond`, so a follow-up routed
// through steer/queue sits undelivered until the clarify's own timeout
// (default 5 min) — the message looks sent and nothing happens. Typing a
// real message instead of picking an option IS the answer "none of these":
// skip the question so the tool returns, then route the words normally.
//
// Fire-and-forget, not awaited: the skip clears the card synchronously and
// both RPCs ride the same socket in call order, so the gateway resolves the
// clarify before it sees the follow-up. Awaiting first would leave the draft
// live for a tick — long enough for a second Enter to send it twice.
if (payloadPresent && !queueEdit && hasClarifyRequest(sessionId)) {
void skipClarifyRequest(sessionId)
}
// Same deal for a pending MCP setup card: the agent is blocked on
// mcp.setup.respond, so a typed message declines the card and rides on.
if (payloadPresent && !queueEdit && hasMcpSetupRequest(sessionId)) {
void skipMcpSetupRequest(sessionId)
}
// Approval / sudo / secret prompts also park the turn inside a tool batch,
// but typing CANNOT answer them (no message text approves a command or
// supplies a password), so there is no skip-and-steer path: a steer would
// sit undelivered behind the blocked prompt, and stopping the turn to force
// it through resolves the prompt to empty and ends the turn as "Operation
// interrupted." — the message looks eaten. Queue the words as the next turn
// instead; the prompt stays answerable and the queue drains on settle.
const blockingPrompt = !queueEdit && hasBlockingPromptRequest(sessionId)
if (queueEdit) {
exitQueuedEdit('save')
} else if (busy) {
// Slash commands should execute immediately even while the agent is
// busy — they're client-side operations (/yolo, /skin, /new, /help,
// etc.) or self-contained gateway RPCs (/status, /compress). onSubmit
// routes them to executeSlashCommand, which has its own per-command
// busy guard for commands that genuinely need an idle session (skill
// /send directives). Queuing them would make every slash command wait
// for the current turn to finish, which is how the TUI never behaves.
if (!attachments.length && SLASH_COMMAND_RE.test(text.trim())) {
triggerHaptic('submit')
clearDraft()
dispatchSubmit(text)
} else if (!compacting && !blockingPrompt && !attachments.length && text.trim()) {
// Cursor-style stop-and-correct: interrupt the live turn and redirect
// it with this text. redirect() preserves the shown reasoning/work; if
// the turn already ended, steerDraft re-queues so nothing is lost.
steerDraft()
} else if (payloadPresent) {
// Attachments can't ride a redirect (no tool-result image carriage) —
// queue the whole payload for the next turn. Same for a turn parked on
// an approval/sudo/secret prompt: a steer can't reach the model while
// the tool batch is blocked, so the message runs as the next turn.
queueCurrentDraft()
} else {
// Stop button (the only way to reach here while busy with an empty
// composer — empty Enter is short-circuited in the keydown handler).
triggerHaptic('cancel')
void Promise.resolve(onCancel())
}
} else if (!payloadPresent && queuedPrompts.length > 0) {
void drainNextQueued()
} else if (payloadPresent) {
const submittedAttachments = cloneAttachments(attachments)
triggerHaptic('submit')
resetBrowseState(sessionId)
clearDraft()
scope.attachments.clear()
dispatchSubmit(text, submittedAttachments)
}
focusInput()
}
// Redirect the live turn with a correction. The gateway either restarts the
// active model request with its displayed context or waits for the current
// tool boundary. If the turn already ended, queue the words instead.
const steerDraft = () => {
const text = draftRef.current.trim()
// Guard on live editor state, not the render-lagged `canSteer`: a redirect
// fired on a fast Enter must not be dropped because state hasn't synced.
if (!onSteer || !text || attachments.length > 0 || SLASH_COMMAND_RE.test(text)) {
return
}
triggerHaptic('submit')
clearDraft()
void Promise.resolve(onSteer(text)).then(accepted => {
if (!accepted && activeQueueSessionKey) {
enqueueQueuedPrompt(activeQueueSessionKey, { text, attachments: [] })
}
})
}
const queueDraft = () => {
if (disabled || !busy) {
return
}
queueCurrentDraft()
focusInput()
}
return { dispatchSubmit, queueDraft, steerDraft, submitDraft }
}
@@ -0,0 +1,309 @@
import type { Unstable_TriggerAdapter, Unstable_TriggerItem } from '@assistant-ui/core'
import { act, renderHook } from '@testing-library/react'
import { createRef } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { rememberDesktopCommandsCatalog } from '@/lib/desktop-slash-commands'
import { composerPlainText, renderComposerContents, RICH_INPUT_SLOT } from '../rich-editor'
import { useComposerTrigger } from './use-composer-trigger'
beforeEach(() => {
rememberDesktopCommandsCatalog({
commands: {
'/goal': { argument_mode: 'mixed', desktop: null },
'/personality': { argument_mode: 'options', desktop: null }
}
})
})
afterEach(() => {
rememberDesktopCommandsCatalog(undefined)
})
/** A live contentEditable seeded with `text`, caret parked at the end. */
function mountEditor(text: string) {
const editor = document.createElement('div')
editor.dataset.slot = RICH_INPUT_SLOT
editor.contentEditable = 'true'
document.body.append(editor)
renderComposerContents(editor, text)
const range = document.createRange()
range.selectNodeContents(editor)
range.collapse(false)
const selection = window.getSelection()!
selection.removeAllRanges()
selection.addRange(range)
return editor
}
const item = (command: string, group = 'Skills'): Unstable_TriggerItem => ({
id: command,
type: 'slash',
label: command.slice(1),
metadata: { command, display: command, meta: '', group, action: '', rawText: command }
})
function mountTrigger(editor: HTMLDivElement, items: Unstable_TriggerItem[]) {
const editorRef = createRef<HTMLDivElement>() as { current: HTMLDivElement | null }
editorRef.current = editor
const draftRef = { current: composerPlainText(editor) }
const adapter: Unstable_TriggerAdapter = {
categories: () => [],
categoryItems: () => [],
search: () => items
}
const setComposerText = vi.fn()
const hook = renderHook(() =>
useComposerTrigger({
at: { adapter: null, loading: false },
draftRef,
editorRef,
requestMainFocus: vi.fn(),
setComposerText,
slash: { adapter, loading: false }
})
)
return { draftRef, hook, setComposerText }
}
describe('useComposerTrigger — slash anywhere in the prompt', () => {
it('opens the completion list for a slash typed mid-message', () => {
const editor = mountEditor('please run /cle')
const { hook } = mountTrigger(editor, [item('/clean')])
act(() => hook.result.current.refreshTrigger())
expect(hook.result.current.trigger).toMatchObject({ kind: '/', inline: true, query: 'cle' })
expect(hook.result.current.triggerItems).toHaveLength(1)
})
it('inserts the picked skill inline and keeps the surrounding prose intact', () => {
const editor = mountEditor('please run /cle')
const { hook } = mountTrigger(editor, [item('/clean')])
act(() => hook.result.current.refreshTrigger())
act(() => hook.result.current.replaceTriggerWithChip(item('/clean')))
// The `/cle` the user typed is replaced by the full command; "please run"
// in front of it survives untouched.
expect(composerPlainText(editor)).toBe('please run /clean ')
})
it('offers only skills mid-message, not app commands', () => {
// `/model` and `/new` act on the app — meaningless as a reference in prose.
const editor = mountEditor('please run /')
const { hook } = mountTrigger(editor, [item('/clean'), item('/model', 'Commands'), item('/new', 'Commands')])
act(() => hook.result.current.refreshTrigger())
expect(hook.result.current.triggerItems.map(i => i.label)).toEqual(['clean'])
})
it('still offers the full command set at the start of the prompt', () => {
const editor = mountEditor('/')
const { hook } = mountTrigger(editor, [item('/clean'), item('/model', 'Commands')])
act(() => hook.result.current.refreshTrigger())
expect(hook.result.current.triggerItems.map(i => i.label)).toEqual(['clean', 'model'])
})
it('still opens the list for a slash at the start of the prompt', () => {
const editor = mountEditor('/cle')
const { hook } = mountTrigger(editor, [item('/clean')])
act(() => hook.result.current.refreshTrigger())
expect(hook.result.current.trigger).toMatchObject({ kind: '/', query: 'cle' })
expect(hook.result.current.trigger?.inline).toBeUndefined()
})
it('leaves a mid-message file path alone', () => {
const editor = mountEditor('open src/foo/bar')
const { hook } = mountTrigger(editor, [item('/clean')])
act(() => hook.result.current.refreshTrigger())
expect(hook.result.current.trigger).toBeNull()
})
it('opens the list for a second slash after a leading command', () => {
// `/work /cle`: the command regex's argument tail would otherwise swallow
// `/cle` as an argument to `/work`, and a no-arg command suppresses the
// popover — so every slash after the first went dead.
const editor = mountEditor('/work /cle')
const { hook } = mountTrigger(editor, [item('/clean')])
act(() => hook.result.current.refreshTrigger())
expect(hook.result.current.trigger).toMatchObject({ kind: '/', inline: true, query: 'cle' })
expect(hook.result.current.triggerItems).toHaveLength(1)
})
it('inserts the second command without disturbing the first', () => {
const editor = mountEditor('/work rewrite the composer /cle')
const { hook } = mountTrigger(editor, [item('/clean')])
act(() => hook.result.current.refreshTrigger())
act(() => hook.result.current.replaceTriggerWithChip(item('/clean')))
expect(composerPlainText(editor)).toBe('/work rewrite the composer /clean ')
})
})
describe('useComposerTrigger — free-text slash arguments', () => {
it('keeps a picked /goal command as editable text while retaining subcommand completion', () => {
const editor = mountEditor('/go')
const goal = item('/goal', 'Commands')
const { hook } = mountTrigger(editor, [goal])
act(() => hook.result.current.refreshTrigger())
act(() => hook.result.current.replaceTriggerWithChip(goal))
expect(composerPlainText(editor)).toBe('/goal ')
expect(editor.querySelector('[data-slash-kind]')).toBeNull()
expect(hook.result.current.trigger).not.toBeNull()
})
it('does not seal a multi-word /goal into a chip when the option list runs empty', () => {
const editor = mountEditor('/goal finish the full prompt')
const { hook } = mountTrigger(editor, [])
act(() => hook.result.current.refreshTrigger())
expect(hook.result.current.slashFreeTextArgStage).toBe(true)
expect(hook.result.current.commitTypedSlashDirective()).toBe(false)
expect(composerPlainText(editor)).toBe('/goal finish the full prompt')
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, [])
act(() => hook.result.current.refreshTrigger())
expect(hook.result.current.slashFreeTextArgStage).toBe(false)
act(() => {
expect(hook.result.current.commitTypedSlashDirective()).toBe(true)
})
expect(composerPlainText(editor)).toBe('/personality creative ')
expect(editor.querySelector('[data-slash-kind]')?.getAttribute('data-ref-text')).toBe('/personality creative')
})
})
describe('useComposerTrigger — chip survival (the plaintext-demotion bug class)', () => {
it('keeps a leading command pill through a Backspace path-ascend', () => {
// The reported repro: `/work @folder…` then Backspace — both chips went
// plaintext because ascend re-rendered the whole editor from text.
const editor = mountEditor('/work @Desktop/')
const { hook } = mountTrigger(editor, [])
expect(editor.querySelector('[data-slash-kind]')).not.toBeNull()
act(() => hook.result.current.refreshTrigger())
expect(hook.result.current.trigger).toMatchObject({ kind: '@', query: 'Desktop/' })
let ran = false
act(() => {
ran = hook.result.current.ascendTriggerPath()
})
expect(ran).toBe(true)
expect(composerPlainText(editor)).toBe('/work @')
expect(editor.querySelector('[data-slash-kind]')).not.toBeNull()
})
it('keeps a leading command pill when a folder pick commits its ref chip', () => {
const editor = mountEditor('/work @Desk')
const folder: Unstable_TriggerItem = {
id: 'folder:Desktop',
type: 'folder',
label: 'Desktop',
metadata: { rawText: '@folder:Desktop', insertId: 'Desktop' }
}
const { hook } = mountTrigger(editor, [folder])
act(() => hook.result.current.refreshTrigger())
act(() => hook.result.current.replaceTriggerWithChip(folder))
expect(composerPlainText(editor)).toBe('/work @folder:`Desktop` ')
expect(editor.querySelector('[data-slash-kind]')).not.toBeNull()
expect(editor.querySelector('[data-ref-kind="folder"]')).not.toBeNull()
})
it('commits in place when Chromium has split the token across text nodes', () => {
// Chromium fragments text nodes around contenteditable=false chips; the
// commit path must span the fragments instead of bailing to a full
// re-render.
const editor = document.createElement('div')
editor.dataset.slot = RICH_INPUT_SLOT
editor.contentEditable = 'true'
document.body.append(editor)
editor.append(document.createTextNode('please run /c'), document.createTextNode('le'))
const caret = document.createRange()
caret.setStart(editor.lastChild!, 2)
caret.collapse(true)
const selection = window.getSelection()!
selection.removeAllRanges()
selection.addRange(caret)
const { hook } = mountTrigger(editor, [item('/clean')])
act(() => hook.result.current.refreshTrigger())
act(() => hook.result.current.replaceTriggerWithChip(item('/clean')))
expect(composerPlainText(editor)).toBe('please run /clean ')
expect(editor.querySelector('[data-slash-kind]')).not.toBeNull()
})
})
@@ -0,0 +1,478 @@
import type { Unstable_TriggerAdapter, Unstable_TriggerItem } from '@assistant-ui/core'
import { type MutableRefObject, type RefObject, useCallback, useEffect, useRef, useState } from 'react'
import { hermesDirectiveFormatter } from '@/components/assistant-ui/directive-text'
import { desktopSlashCommandArgumentMode } from '@/lib/desktop-slash-commands'
import {
COMPLETION_ACTIONS,
isSkillItem,
slashArgStage,
slashChipKindForItem,
slashCommandToken
} from '../composer-utils'
import {
appendComposerContents,
caretOffsetInEditor,
composerPlainText,
placeCaretAtOffset,
refChipElement,
renderComposerContents,
replaceBeforeCaret,
RICH_INPUT_SLOT,
slashChipElement
} from '../rich-editor'
import { detectTrigger, textBeforeCaret, type TriggerState } from '../text-utils'
/**
* Rebuild-from-text fallback for carets the range walk can't anchor (a
* non-collapsed selection, a caret not preceded by contiguous text). It
* re-renders the whole editor from serialized text, so it only runs when the
* in-place path reports failure — never as the default.
*
* The split is around the CARET, not the end of the draft. Slicing
* `length - tokenLength` off the end assumed the trigger token was the last
* thing in the editor: a completion picked mid-message chopped the trailing
* prose off and stranded a partial `folder:` in front of the chip, because the
* window it removed wasn't the token the user was typing.
*/
/** The keyup half of trigger detection, shared by both composers.
*
* If the open popover already consumed this key in keydown (Arrow/Enter/Tab/
* Escape), skip the refresh: those keys never edit text, and for Escape the
* keydown already closed the menu — refreshing here would re-detect the
* still-present `/` and instantly reopen it. It reads a ref set during keydown
* rather than `trigger`, because by keyup time React has re-rendered and
* `trigger` may already be null. */
export function triggerKeyUpHandler(consumedRef: MutableRefObject<boolean>, refreshTrigger: () => void) {
return () => {
if (consumedRef.current) {
consumedRef.current = false
return
}
window.setTimeout(refreshTrigger, 0)
}
}
export function rebuildAroundCaret(editor: HTMLDivElement, tokenLength: number, insert: DocumentFragment | string) {
const current = composerPlainText(editor)
const caret = caretOffsetInEditor(editor)
const prefix = current.slice(0, Math.max(0, caret - tokenLength))
const suffix = current.slice(caret)
if (typeof insert === 'string') {
renderComposerContents(editor, `${prefix}${insert}${suffix}`)
placeCaretAtOffset(editor, prefix.length + insert.length)
return
}
// Measure before appending — moving a fragment empties it. Appending the
// element rather than re-serializing keeps mid-message slash pills alive:
// they have no text hydration, unlike `@` refs and the leading command.
const scratch = document.createElement('div')
scratch.dataset.slot = RICH_INPUT_SLOT
scratch.append(insert.cloneNode(true))
const inserted = composerPlainText(scratch)
renderComposerContents(editor, prefix)
editor.append(insert)
appendComposerContents(editor, suffix)
placeCaretAtOffset(editor, prefix.length + inserted.length)
}
interface CompletionSource {
adapter: Unstable_TriggerAdapter | null
loading: boolean
}
interface UseComposerTriggerOptions {
at: CompletionSource
draftRef: MutableRefObject<string>
editorRef: RefObject<HTMLDivElement | null>
/** `:joy` emoji completions — inserts the emoji character, never a chip. */
emoji?: CompletionSource
/** Bank the pre-commit state so a popover pick is a single undo step. */
recordUndoPoint?: () => void
requestMainFocus: () => void
setComposerText: (text: string) => void
slash: CompletionSource
}
/**
* Trigger / completion engine: `@`/`/` detection against the live editor, the
* adapter-driven item list, the open popover's selection state, and the chip
* insertion that commits a pick back into the contentEditable. Owns the trigger
* state; ChatBar threads its editor refs in and consumes the returned API from
* the input/keydown/keyup paths + the popover render. `triggerKeyConsumedRef` is
* exposed so keydown can mark a navigation/control key as handled and the
* subsequent keyup skips its refresh.
*/
export function useComposerTrigger({
at,
draftRef,
editorRef,
emoji,
recordUndoPoint,
requestMainFocus,
setComposerText,
slash
}: UseComposerTriggerOptions) {
const [trigger, setTrigger] = useState<TriggerState | null>(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<readonly Unstable_TriggerItem[]>([])
// Set synchronously in keydown when the open trigger popover consumes a
// navigation/control key (Arrow/Enter/Tab/Escape). The subsequent keyup must
// NOT run refreshTrigger for that keypress: it never edits text, and for
// Escape the keydown has already set trigger=null, so a keyup refresh would
// re-detect the still-present `/` and instantly reopen the menu. A ref is
// used instead of reading `trigger` in keyup because by keyup time React has
// 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
if (!editor) {
return
}
// Fast-bail: if neither `@` nor `/` appears in the current draft, there's
// nothing for `detectTrigger` to match. Use `textContent` (cheap browser-
// native walk) for the precondition check rather than `composerPlainText`
// (recursive child walk with chip-aware logic). Only when a trigger char
// is present do we pay the cost of the full walk + DOM range work.
const rawText = editor.textContent ?? ''
if (!rawText.includes('@') && !rawText.includes('/') && !rawText.includes(':')) {
if (trigger) {
setTrigger(null)
resetTriggerActive()
}
return
}
const before = textBeforeCaret(editor)
const found = detectTrigger(before ?? composerPlainText(editor))
// A text-only command has no completion screen once its prose begins. Mixed
// commands such as /goal stay live so their finite subcommands can still be
// suggested, while arbitrary goal text remains valid.
const argumentMode =
found?.kind === '/' && slashArgStage(found.query)
? desktopSlashCommandArgumentMode(slashCommandToken(found.query))
: null
const detected =
found?.kind === '/' && slashArgStage(found.query) && argumentMode !== 'options' && argumentMode !== 'mixed'
? null
: found
setTrigger(detected)
// Only reset the highlight when the trigger actually changed (opened, or
// the query/kind differs). Re-detecting the *same* trigger — e.g. on a
// 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) {
resetTriggerActive()
}
}, [editorRef, resetTriggerActive, trigger])
const triggerAdapter: Unstable_TriggerAdapter | null =
trigger?.kind === '@'
? at.adapter
: trigger?.kind === '/'
? slash.adapter
: trigger?.kind === ':'
? (emoji?.adapter ?? null)
: null
useEffect(() => {
if (!trigger || !triggerAdapter?.search) {
setTriggerItems([])
return
}
const items = triggerAdapter.search(trigger.query)
// Mid-message only offers SKILLS. A built-in like `/model` or `/new` acts
// on the app, so it's meaningless as a reference inside prose — only a
// skill reads as "handle this part with X". Filtering here rather than in
// the fetcher keeps one completion source for both shapes.
setTriggerItems(trigger.inline ? items.filter(isSkillItem) : items)
}, [trigger, triggerAdapter])
const triggerLoading =
trigger?.kind === '@'
? at.loading
: trigger?.kind === '/'
? slash.loading
: trigger?.kind === ':'
? (emoji?.loading ?? false)
: false
// Suppress the "No matches" empty state once a slash command is past its name:
// a no-arg command has nothing to offer, and a fully-typed arg commits on
// Space/Tab — neither should dead-end on a popover.
const argStageEmpty = trigger?.kind === '/' && slashArgStage(trigger.query) && !triggerLoading && !triggerItems.length
const slashArgumentMode =
trigger?.kind === '/' && slashArgStage(trigger.query)
? desktopSlashCommandArgumentMode(slashCommandToken(trigger.query))
: null
const slashFreeTextArgStage = slashArgumentMode === 'mixed' || slashArgumentMode === 'text'
const closeTrigger = () => {
setTrigger(null)
setTriggerItems([])
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(() => {
setTriggerActive(idx => Math.min(idx, Math.max(0, triggerItems.length - 1)))
}, [triggerItems.length])
// Commit the literally-typed `/command arg` as a directive chip — used when
// the completion list is empty because the arg is already fully typed (the
// backend completer drops exact matches). Reuses the chip path via a
// synthetic item whose serialized form is the verbatim text.
const commitTypedSlashDirective = (): boolean => {
if (trigger?.kind !== '/') {
return false
}
// Free prose must stay ordinary contentEditable text. This guard also
// protects against a stale completion result reaching the keydown path
// before refreshTrigger has caught up with the latest DOM input.
if (desktopSlashCommandArgumentMode(slashCommandToken(trigger.query)) !== 'options') {
return false
}
const text = `/${trigger.query.trimEnd()}`
replaceTriggerWithChip({
id: text,
type: 'slash',
label: text.slice(1),
metadata: {
command: slashCommandToken(trigger.query),
display: text,
meta: '',
group: '',
action: '',
rawText: text
}
})
return true
}
const replaceTriggerWithChip = (item: Unstable_TriggerItem, options?: { descend?: boolean }) => {
const editor = editorRef.current
if (!editor || !trigger) {
return
}
// Bank the pre-commit state first — every path below mutates the editor,
// and a pick must be exactly one undo step.
recordUndoPoint?.()
const rebuildAround = (insert: DocumentFragment | string) => rebuildAroundCaret(editor, trigger.tokenLength, insert)
// Action items (e.g. "Browse all sessions…") run a side effect instead of
// inserting a chip: strip the typed trigger token, then fire the action.
const completionAction = (item.metadata as { action?: unknown } | undefined)?.action
const runAction = typeof completionAction === 'string' ? COMPLETION_ACTIONS[completionAction] : undefined
if (runAction) {
if (!replaceBeforeCaret(editor, trigger.tokenLength, document.createDocumentFragment())) {
rebuildAround('')
}
draftRef.current = composerPlainText(editor)
setComposerText(draftRef.current)
closeTrigger()
runAction()
requestMainFocus()
return
}
const serialized = hermesDirectiveFormatter.serialize(item)
const starter = serialized.endsWith(':')
// Tab on a folder walks INTO it instead of committing it: re-type the
// token as the bare path so the next `complete.path` lists that folder's
// children, exactly as typing the path by hand would. Enter still commits
// the folder itself — the two intents are distinct, so the keys are too.
// Only `@` folders descend; a slash command's arg list has no hierarchy.
const descendInto =
options?.descend && trigger.kind === '@' && item.type === 'folder'
? String((item.metadata as { insertId?: unknown } | undefined)?.insertId ?? '')
: ''
const finish = (keepOpen: boolean) => {
draftRef.current = composerPlainText(editor)
setComposerText(draftRef.current)
requestMainFocus()
keepOpen ? window.setTimeout(refreshTrigger, 0) : closeTrigger()
}
if (descendInto) {
const path = descendInto.endsWith('/') ? descendInto : `${descendInto}/`
// Carry the browse scope down with the path. Dropping it turned an
// explicit `@folder:` browse into a bare `@apps/desktop/` token halfway
// through, so the next completion silently widened back to files and the
// committed chip had to re-guess the kind from a trailing slash.
const scope = trigger.scope ? `${trigger.scope}:` : ''
const fragment = document.createDocumentFragment()
fragment.append(document.createTextNode(`@${scope}${path}`))
if (!replaceBeforeCaret(editor, trigger.tokenLength, fragment)) {
rebuildAround(`@${scope}${path}`)
}
return finish(true)
}
// Picking a bare arg-taking command (e.g. `/personality`) shouldn't commit
// it — expand to its options step so the popover shows the inline list, just
// as typing `/personality ` by hand would. A serialized value with a space is
// already an arg pick (`/personality alice`), so it commits normally. An
// inline (mid-message) pick never expands: it's a reference inside prose, so
// there's no command invocation for the args to belong to.
const command = (item.metadata as { command?: string } | undefined)?.command ?? ''
const argumentMode = desktopSlashCommandArgumentMode(command)
const expandsToArgs = trigger.kind === '/' && !trigger.inline && !serialized.includes(' ') && argumentMode !== null
const text = starter || serialized.endsWith(' ') ? serialized : `${serialized} `
const directive = !starter && serialized.match(/^@([^:]+):(.+)$/)
// No pill while expanding — the bare command stays plain text until an arg
// is picked, at which point a single pill is emitted for the full command.
const slashKind = !expandsToArgs && trigger.kind === '/' ? slashChipKindForItem(item) : null
const keepTriggerOpen = starter || (expandsToArgs && argumentMode !== 'text')
const chip = slashKind
? slashChipElement(serialized, slashKind)
: directive
? // Carry the picked row's own label into the chip rather than letting
// it re-derive one from the value. Upstream's DirectiveNode does the
// same (`__label = item.label`), and it's what makes the list and the
// chip agree: you get the string you just read, not a second guess at
// it. Falls back to the shared deriver for callers with no label.
refChipElement(directive[1], directive[2], (item.metadata as { display?: string })?.display || item.label)
: null
// The trailing space is a convenience for "keep typing after the chip", so
// it's wrong when the caret already has whitespace in front of it — a pick
// made mid-sentence would leave a double space in the prose.
const followedBySpace = /^\s/.test(composerPlainText(editor).slice(caretOffsetInEditor(editor)))
const fragment = document.createDocumentFragment()
chip
? fragment.append(chip, ...(followedBySpace ? [] : [document.createTextNode(' ')]))
: fragment.append(document.createTextNode(followedBySpace ? text.trimEnd() : text))
if (!replaceBeforeCaret(editor, trigger.tokenLength, fragment)) {
// The failed in-place attempt never consumed the fragment, so the chip +
// trailing space are re-inserted around the caret here. Moving the
// element (rather than re-serializing) keeps mid-message slash pills
// alive — they have no text hydration, unlike `@` refs and the leading
// command.
rebuildAround(chip ? fragment : text)
}
finish(keepTriggerOpen)
}
/** Backspace inside an `@` path drops the last segment (`a/b/` → `a/`)
* instead of one character, and once the path is empty it drops the browse
* scope (`@folder:` → `@`) rather than nibbling `:`, `r`, `e`, `d`… back
* through the directive syntax the user never typed. Descending is one Tab
* per level, so climbing back out costs one key per level too. Returns
* false when the caret isn't in a path, so keydown falls through. */
const ascendTriggerPath = () => {
const editor = editorRef.current
if (!editor || trigger?.kind !== '@') {
return false
}
const scope = trigger.scope ? `${trigger.scope}:` : ''
if (!trigger.value.includes('/') && !scope) {
return false
}
// Trailing slash means we're listing a folder's children: drop that
// folder. Otherwise a partial segment is typed — drop just that. With the
// value already empty, the only thing left to drop is the scope itself.
const trimmed = trigger.value.replace(/\/$/, '')
const parent = trimmed.slice(0, trimmed.lastIndexOf('/') + 1)
const next = trigger.value ? `${scope}${parent}` : ''
recordUndoPoint?.()
const fragment = document.createDocumentFragment()
fragment.append(document.createTextNode(`@${next}`))
// In place first: the destructive re-render fallback rebuilds the editor
// from text, which is exactly what used to demote a leading command pill
// to plaintext on every Backspace inside a path.
if (!replaceBeforeCaret(editor, trigger.tokenLength, fragment)) {
rebuildAroundCaret(editor, trigger.tokenLength, `@${next}`)
}
draftRef.current = composerPlainText(editor)
setComposerText(draftRef.current)
window.setTimeout(refreshTrigger, 0)
return true
}
return {
argStageEmpty,
ascendTriggerPath,
closeTrigger,
commitTypedSlashDirective,
moveTriggerActive,
refreshTrigger,
replaceTriggerWithChip,
setTriggerActive,
slashFreeTextArgStage,
trigger,
triggerActive,
triggerActiveExplicit,
triggerItems,
triggerKeyConsumedRef,
triggerLoading
}
}
@@ -0,0 +1,186 @@
import { render } from '@testing-library/react'
import { createRef, type RefObject } from 'react'
import { describe, expect, it, vi } from 'vitest'
import { placeCaretAtEnd } from '../test-utils'
import { useComposerUndo } from './use-composer-undo'
/** Mount the hook against a real contentEditable, exposing its API. */
function mountUndo(editorRef: RefObject<HTMLDivElement | null>, onSync: () => string) {
const api: { current: ReturnType<typeof useComposerUndo> | null } = { current: null }
const Harness = () => {
// Assigned during render on purpose: the tests drive the API imperatively
// right after mount, and this is a harness, not app state.
api.current = useComposerUndo({ editorRef, syncDraftFromEditor: onSync })
return null
}
const view = render(<Harness />)
return { api, view }
}
function makeEditor(text: string) {
const editor = document.createElement('div')
editor.contentEditable = 'true'
// jsdom only focuses a contentEditable div when it's explicitly focusable;
// the real editor is reachable via the composer's focus bus.
editor.tabIndex = 0
editor.append(document.createTextNode(text))
document.body.append(editor)
const ref = createRef<HTMLDivElement>() as RefObject<HTMLDivElement | null>
ref.current = editor
return { editor, ref }
}
describe('useComposerUndo', () => {
it('restores the pre-edit text, which is what a paste destroyed', () => {
const { editor, ref } = makeEditor('before')
placeCaretAtEnd(editor)
const { api, view } = mountUndo(ref, () => editor.textContent || '')
// Bank, then simulate the Range-based paste that Chromium never records.
api.current!.recordUndoPoint()
editor.append(document.createTextNode(' PASTED'))
expect(editor.textContent).toBe('before PASTED')
api.current!.undo()
expect(editor.textContent).toBe('before')
api.current!.redo()
expect(editor.textContent).toBe('before PASTED')
view.unmount()
editor.remove()
})
it('withUndoPoint banks only when the edit actually ran', () => {
const { editor, ref } = makeEditor('text')
placeCaretAtEnd(editor)
const { api, view } = mountUndo(ref, () => editor.textContent || '')
// A guard that declines must not consume an undo slot.
expect(api.current!.withUndoPoint(() => false)).toBe(false)
expect(api.current!.undo()).toBe(false)
expect(
api.current!.withUndoPoint(() => {
editor.append(document.createTextNode('!'))
return true
})
).toBe(true)
api.current!.undo()
expect(editor.textContent).toBe('text')
view.unmount()
editor.remove()
})
it('claims a native historyUndo aimed at the focused editor', () => {
const { editor, ref } = makeEditor('kept')
editor.focus()
placeCaretAtEnd(editor)
const { api, view } = mountUndo(ref, () => editor.textContent || '')
api.current!.recordUndoPoint()
editor.append(document.createTextNode(' extra'))
// What Electron's Edit menu `{ role: 'undo' }` produces.
const event = new InputEvent('beforeinput', { bubbles: true, cancelable: true, inputType: 'historyUndo' })
editor.dispatchEvent(event)
expect(event.defaultPrevented).toBe(true)
expect(editor.textContent).toBe('kept')
view.unmount()
editor.remove()
})
it('ignores a historyUndo while another editor holds focus', () => {
const { editor, ref } = makeEditor('mine')
const { editor: other } = makeEditor('theirs')
other.focus()
const { api, view } = mountUndo(ref, () => editor.textContent || '')
api.current!.recordUndoPoint()
editor.append(document.createTextNode(' changed'))
const event = new InputEvent('beforeinput', { bubbles: true, cancelable: true, inputType: 'historyUndo' })
other.dispatchEvent(event)
// Not ours to claim — the other surface keeps its native behavior.
expect(event.defaultPrevented).toBe(false)
expect(editor.textContent).toBe('mine changed')
view.unmount()
editor.remove()
other.remove()
})
it('keeps two mounted composers independent', () => {
const { editor: main, ref: mainRef } = makeEditor('main')
const { editor: edit, ref: editRef } = makeEditor('edit')
const mainUndo = mountUndo(mainRef, () => main.textContent || '')
const editUndo = mountUndo(editRef, () => edit.textContent || '')
mainUndo.api.current!.recordUndoPoint()
main.append(document.createTextNode(' typed'))
// Undoing in the edit composer must not touch the main composer's text.
editUndo.api.current!.undo()
expect(main.textContent).toBe('main typed')
mainUndo.api.current!.undo()
expect(main.textContent).toBe('main')
expect(edit.textContent).toBe('edit')
mainUndo.view.unmount()
editUndo.view.unmount()
main.remove()
edit.remove()
})
it('reset drops history so undo cannot cross a draft swap', () => {
const { editor, ref } = makeEditor('session A')
placeCaretAtEnd(editor)
const { api, view } = mountUndo(ref, () => editor.textContent || '')
api.current!.recordUndoPoint()
editor.append(document.createTextNode(' edited'))
api.current!.resetUndoHistory()
expect(api.current!.undo()).toBe(false)
expect(editor.textContent).toBe('session A edited')
view.unmount()
editor.remove()
})
it('is inert when the editor ref is empty', () => {
const ref = createRef<HTMLDivElement>() as RefObject<HTMLDivElement | null>
const sync = vi.fn(() => '')
const { api, view } = mountUndo(ref, sync)
api.current!.recordUndoPoint()
expect(api.current!.undo()).toBe(false)
expect(sync).not.toHaveBeenCalled()
view.unmount()
})
})
@@ -0,0 +1,119 @@
import { type RefObject, useCallback, useEffect, useMemo } from 'react'
import { caretOffsetInEditor, composerPlainText, placeCaretAtOffset, renderComposerContents } from '../rich-editor'
import { type ComposerSnapshot, createComposerUndoHistory } from '../undo-history'
interface UseComposerUndoArgs {
editorRef: RefObject<HTMLDivElement | null>
/** Push a restored snapshot back into draftRef + composer state. */
syncDraftFromEditor: () => string
}
/**
* Undo/redo for the rich composer.
*
* The editor mutates its DOM through `Range` to dodge Chromium's O(n²) editing
* pipeline (#45812), which also dodges Chromium's undo stack — so a paste was
* invisible to ⌘Z and the keystroke undid whatever edit came before it instead.
* We own the stack outright rather than half of it: every edit path records the
* pre-edit state here, and the editor claims ⌘Z / ⌘⇧Z itself.
*/
export function useComposerUndo({ editorRef, syncDraftFromEditor }: UseComposerUndoArgs) {
const history = useMemo(() => createComposerUndoHistory(), [])
const snapshot = useCallback((): ComposerSnapshot => {
const editor = editorRef.current
if (!editor) {
return { caret: 0, text: '' }
}
return { caret: caretOffsetInEditor(editor), text: composerPlainText(editor) }
}, [editorRef])
/** Bank the current state before mutating the editor. `coalesce` marks a
* keystroke, so a run of typing collapses into one undo step. */
const recordUndoPoint = useCallback(
(options?: { coalesce?: boolean }) => {
if (editorRef.current) {
history.record(snapshot(), options)
}
},
[editorRef, history, snapshot]
)
const applySnapshot = useCallback(
(next: ComposerSnapshot | null) => {
const editor = editorRef.current
if (!next || !editor) {
return false
}
renderComposerContents(editor, next.text)
placeCaretAtOffset(editor, next.caret)
syncDraftFromEditor()
return true
},
[editorRef, syncDraftFromEditor]
)
/** Run a conditional edit, banking its pre-edit state only if it actually
* ran. The snapshot has to be taken first (the edit destroys the state we'd
* be saving), but recording unconditionally would clear the redo stack on
* every Backspace that falls through to the native path. */
const withUndoPoint = useCallback(
(edit: () => boolean) => {
const before = snapshot()
const ran = edit()
if (ran) {
history.record(before)
}
return ran
},
[history, snapshot]
)
const undo = useCallback(() => applySnapshot(history.undo(snapshot())), [applySnapshot, history, snapshot])
const redo = useCallback(() => applySnapshot(history.redo(snapshot())), [applySnapshot, history, snapshot])
// A session/draft swap makes prior history meaningless — undoing into another
// conversation's text is worse than having no history at all.
const resetUndoHistory = useCallback(() => history.reset(), [history])
// Electron's Edit menu ships `{ role: 'undo' }`, whose accelerator the macOS
// menu bar consumes before the web contents sees the keystroke (the same
// hazard main.ts documents for ⌘W). It fires the native editing command,
// which knows nothing about our stack. Claim it at the document level while
// the composer holds focus, so the menu item and the keystroke agree.
useEffect(() => {
const onBeforeInput = (event: Event) => {
const inputType = (event as InputEvent).inputType
if (inputType !== 'historyUndo' && inputType !== 'historyRedo') {
return
}
if (document.activeElement !== editorRef.current) {
return
}
event.preventDefault()
if (inputType === 'historyUndo') {
undo()
} else {
redo()
}
}
document.addEventListener('beforeinput', onBeforeInput, true)
return () => document.removeEventListener('beforeinput', onBeforeInput, true)
}, [editorRef, redo, undo])
return { recordUndoPoint, redo, resetUndoHistory, undo, withUndoPoint }
}
@@ -0,0 +1,48 @@
import { act, renderHook } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { useComposerUrlDialog } from './use-composer-url-dialog'
vi.mock('@/lib/haptics', () => ({ triggerHaptic: () => {} }))
describe('useComposerUrlDialog', () => {
it('drops an @url: directive into the draft when there is no host onAddUrl', () => {
const insertText = vi.fn()
const { result } = renderHook(() => useComposerUrlDialog({ insertText }))
act(() => result.current.setUrlValue(' https://example.dev '))
act(() => result.current.submitUrl())
// The trailing/leading whitespace is trimmed before building the directive.
expect(insertText).toHaveBeenCalledWith('@url:https://example.dev')
})
it('prefers the host onAddUrl handler, then clears + closes the dialog', () => {
const insertText = vi.fn()
const onAddUrl = vi.fn()
const { result } = renderHook(() => useComposerUrlDialog({ insertText, onAddUrl }))
act(() => {
result.current.openUrlDialog()
result.current.setUrlValue(' https://example.dev ')
})
act(() => result.current.submitUrl())
expect(onAddUrl).toHaveBeenCalledWith('https://example.dev')
expect(insertText).not.toHaveBeenCalled()
expect(result.current.urlValue).toBe('')
expect(result.current.urlOpen).toBe(false)
})
it('no-ops on an empty / whitespace-only URL', () => {
const insertText = vi.fn()
const onAddUrl = vi.fn()
const { result } = renderHook(() => useComposerUrlDialog({ insertText, onAddUrl }))
act(() => result.current.setUrlValue(' '))
act(() => result.current.submitUrl())
expect(insertText).not.toHaveBeenCalled()
expect(onAddUrl).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,50 @@
import { useEffect, useRef, useState } from 'react'
import { triggerHaptic } from '@/lib/haptics'
interface UseComposerUrlDialogOptions {
insertText: (text: string) => void
onAddUrl?: (url: string) => void
}
/**
* "Add URL" dialog engine: open/value state, autofocus-on-open, and submit. On
* submit it prefers the host's `onAddUrl` (which may fetch/title the link) and
* otherwise drops an `@url:` directive into the draft.
*/
export function useComposerUrlDialog({ insertText, onAddUrl }: UseComposerUrlDialogOptions) {
const urlInputRef = useRef<HTMLInputElement | null>(null)
const [urlOpen, setUrlOpen] = useState(false)
const [urlValue, setUrlValue] = useState('')
useEffect(() => {
if (urlOpen) {
window.requestAnimationFrame(() => urlInputRef.current?.focus({ preventScroll: true }))
}
}, [urlOpen])
const openUrlDialog = () => {
triggerHaptic('open')
setUrlOpen(true)
}
const submitUrl = () => {
const url = urlValue.trim()
if (!url) {
return
}
if (onAddUrl) {
onAddUrl(url)
} else {
insertText(`@url:${url}`)
}
triggerHaptic('success')
setUrlValue('')
setUrlOpen(false)
}
return { openUrlDialog, setUrlOpen, setUrlValue, submitUrl, urlInputRef, urlOpen, urlValue }
}
@@ -0,0 +1,322 @@
import { useStore } from '@nanostores/react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useI18n } from '@/i18n'
import { chatMessageText, collectUnspokenTurnSpeech } from '@/lib/chat-messages'
import { triggerHaptic } from '@/lib/haptics'
import { markAssistantIdSpoken, resolveSpokenReply } from '@/lib/spoken-reply'
import { CONVERSATION_LEASE, READ_ALOUD_LEASE, syncTtsLease } from '@/lib/tts-lease'
import { clearWakeIndicator, syncWakeIndicatorWithVoice } from '@/lib/wake-indicator'
import { $voiceConversationStartRequest, takeVoiceConversationStart } from '@/store/composer'
import { resetBrowseState } from '@/store/composer-input-history'
import { $gateway } from '@/store/gateway'
import { notify, notifyError } from '@/store/notifications'
import { $autoSpeakReplies, $voiceStopPhrase, setAutoSpeakReplies } from '@/store/voice-prefs'
import { resumeWakeAfterVoice } from '@/store/wake-word'
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'
import { useVoiceConversation } from './use-voice-conversation'
import { useVoiceRecorder } from './use-voice-recorder'
interface UseComposerVoiceArgs {
busy: boolean
clearDraft: () => void
disabled: boolean
focusInput: () => void
insertText: (text: string) => void
maxRecordingSeconds: number
/** Interrupt the in-flight agent turn (Stop-button seam) — fired when the
* user speaks over the model while it is still generating. */
onInterrupt?: () => Promise<void> | void
onSubmit: ChatBarProps['onSubmit']
onTranscribeAudio: ChatBarProps['onTranscribeAudio']
sessionId: string | null | undefined
/** This composer's focus-bus key — voice toggles targeting another
* composer (or the active one, when not us) are ignored. */
target: ComposerTarget
}
/**
* The composer's voice engine: push-to-talk dictation (transcript → draft), the
* full voice-conversation loop, and auto-speak of replies. Self-contained — it
* consumes the draft/submit primitives passed in but nothing depends back on it,
* so it lifts cleanly out of ChatBar.
*/
export function useComposerVoice({
busy,
clearDraft,
disabled,
focusInput,
insertText,
maxRecordingSeconds,
onInterrupt,
onSubmit,
onTranscribeAudio,
sessionId,
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 ownsWakeIndicatorRef = useRef(false)
const voiceStartRequest = useStore($voiceConversationStartRequest)
const { dictate, voiceActivityState, voiceStatus } = useVoiceRecorder({
focusInput,
maxRecordingSeconds,
onTranscript: insertText,
onTranscribeAudio
})
/** Auto-speak selector: the latest unspoken reply only — a backlog collapses to the newest. */
const pendingResponse = () => {
const messages = $messages.get()
const last = messages.findLast(m => m.role === 'assistant' && !m.hidden)
const spoken = resolveSpokenReply(sessionId, messages)
if (!last || last.id === spoken?.id) {
return null
}
const text = chatMessageText(last).trim()
if (!text) {
return null
}
return {
id: last.id,
pending: Boolean(last.pending),
text
}
}
/**
* Voice-conversation selector: every unspoken assistant bubble of the turn,
* in order — narration interims AND the final answer, not just whichever
* bubble happens to be last. See `collectUnspokenTurnSpeech`.
*/
const pendingTurnResponse = () => {
const messages = $messages.get()
return collectUnspokenTurnSpeech(messages, resolveSpokenReply(sessionId, messages)?.id ?? null)
}
const consumePendingResponse = () => {
const messages = $messages.get()
const last = messages.findLast(m => m.role === 'assistant' && !m.hidden)
if (last) {
markAssistantIdSpoken(sessionId, messages, last.id)
}
}
const submitVoiceTurn = async (text: string) => {
if (busy) {
return
}
triggerHaptic('submit')
resetBrowseState(sessionId)
clearDraft()
await onSubmit(text)
}
const wakePausedRef = useRef(false)
// Resolves once the in-flight wake.pause round-trip completes (mic released by
// the wake listener). The conversation awaits this before opening its own mic
// so the two never contend for the device — on Windows especially, opening the
// capture device while the wake listener still holds it makes getUserMedia
// fail and the conversation never starts listening.
const wakePauseBarrierRef = useRef<Promise<void> | null>(null)
const conversation = useVoiceConversation({
busy,
consumePendingResponse,
enabled: voiceConversationActive,
onFatalError: () => setVoiceConversationActive(false),
// Speaking over the model mid-generation interrupts the in-flight turn —
// the same seam as the Stop button — so the interjection becomes the next
// turn instead of waiting behind a reply the user already rejected.
onInterrupt,
// A spoken stop command ("stop", "never mind", "goodbye", …) ends the
// hands-free conversation. Flipping the flag is the authoritative off
// switch — the enabled=false prop + effect below drive conversation.end()
// teardown (mic close, wake re-arm).
onStopWord: () => setVoiceConversationActive(false),
onSubmit: submitVoiceTurn,
onTranscribeAudio,
pendingResponse: pendingTurnResponse,
// Before the conversation opens the mic, wait for any in-flight wake.pause
// to finish releasing the capture device (see wakePauseBarrierRef).
beforeMicOpen: () => wakePauseBarrierRef.current ?? undefined
})
// eslint-disable-next-line no-restricted-syntax -- ownership token used only by unmount cleanup
useEffect(() => {
if (target !== 'main') {
return
}
if (syncWakeIndicatorWithVoice(voiceConversationActive, conversation.status)) {
ownsWakeIndicatorRef.current = voiceConversationActive
}
}, [conversation.status, target, voiceConversationActive])
useEffect(
() => () => {
if (ownsWakeIndicatorRef.current) {
clearWakeIndicator()
}
},
[]
)
// The `composer.voice` hotkey (Ctrl+B) toggles the conversation. Starting
// with STT unconfigured lets the conversation surface its own "configure
// speech-to-text" notice rather than silently no-opping.
const toggleVoiceConversation = useCallback(() => {
if (disabled) {
return
}
if (voiceConversationActive) {
setVoiceConversationActive(false)
void conversation.end()
} else {
setVoiceConversationActive(true)
}
}, [conversation, disabled, voiceConversationActive])
useEffect(
() => onComposerVoiceToggleRequest(toggled => toggled === target && toggleVoiceConversation()),
[target, toggleVoiceConversation]
)
useEffect(() => {
if (target === 'main' && !disabled && takeVoiceConversationStart(voiceStartRequest) && !voiceConversationActive) {
setVoiceConversationActive(true)
}
}, [disabled, target, voiceConversationActive, voiceStartRequest])
const resumeWakeIfPaused = useCallback(() => {
if (!wakePausedRef.current) {
return
}
wakePausedRef.current = false
wakePauseBarrierRef.current = null
// Reconcile, don't just resume: the wake word is a persistent setting, so
// ending a voice chat must re-arm the listener whenever config says
// enabled — including when the raw resume loses the mic-release race.
void resumeWakeAfterVoice()
}, [])
// The ref is a request token (did WE issue wake.pause?), not an atom mirror —
// it guards resumeWakeIfPaused from resuming a detector another surface owns.
const pauseWakeForVoice = useCallback(() => {
wakePausedRef.current = true
const barrier = (async () => {
try {
await $gateway.get()?.request('wake.pause', {})
} catch {
// No wake listener / older backend — nothing held the mic.
}
})()
wakePauseBarrierRef.current = barrier
return barrier
}, [])
useEffect(() => {
if (voiceConversationActive) {
pauseWakeForVoice()
} else {
resumeWakeIfPaused()
}
}, [pauseWakeForVoice, resumeWakeIfPaused, voiceConversationActive])
// 'Say "stop" to end the voice chat.' notice when the conversation starts.
// Phrase comes from voice.stop_phrases (first entry) so a custom phrase
// renders correctly; a null phrase (stop_phrases: []) shows no notice.
useEffect(() => {
if (!voiceConversationActive) {
return
}
const phrase = $voiceStopPhrase.get()
if (phrase) {
notify({
id: 'voice-stop-hint',
kind: 'info',
icon: 'mic',
message: t.notifications.voice.sayStopToEnd(phrase)
})
}
}, [t, voiceConversationActive])
useEffect(() => resumeWakeIfPaused, [resumeWakeIfPaused])
// Speech-output toggles are TTS warm-up / release signals. Entering a voice
// conversation acquires this window's lease (pre-loads the engine so the
// first spoken reply doesn't start with dead air); ending it releases the
// lease, and the backend unloads resident local models once no surface holds
// one. Fire-and-forget — the toggle never waits on or fails from this.
useEffect(() => {
void syncTtsLease(CONVERSATION_LEASE, voiceConversationActive)
}, [voiceConversationActive])
useEffect(() => () => void syncTtsLease(CONVERSATION_LEASE, false), [])
// "Read replies aloud" is the same signal, held for as long as the toggle is
// on (it mirrors voice.auto_tts, so this also warms at startup when the
// preference is already set).
const autoSpeakReplies = useStore($autoSpeakReplies)
useEffect(() => {
void syncTtsLease(READ_ALOUD_LEASE, autoSpeakReplies)
}, [autoSpeakReplies])
// Explicit start/end for the on-screen conversation controls (the hotkey uses
// the gated toggle above).
const startConversation = useCallback(() => setVoiceConversationActive(true), [])
const endConversation = useCallback(() => {
setVoiceConversationActive(false)
void conversation.end()
}, [conversation])
const handleToggleAutoSpeak = useCallback(() => {
void setAutoSpeakReplies(!$autoSpeakReplies.get()).catch(error =>
notifyError(error, t.settings.config.autosaveFailed)
)
}, [t])
useAutoSpeakReplies({
conversationActive: voiceConversationActive,
failureLabel: t.assistant.thread.readAloudFailed,
markSpoken: consumePendingResponse,
pendingReply: pendingResponse,
sessionId
})
return {
conversation,
dictate,
endConversation,
handleToggleAutoSpeak,
startConversation,
voiceActivityState,
voiceConversationActive,
voiceStatus
}
}
@@ -0,0 +1,122 @@
import { useCallback } from 'react'
import { type CompletionEntry, type CompletionPayload, useLiveCompletionAdapter } from './use-live-completion-adapter'
/**
* `:shortcode:` completions for the composers, Slack-style (`:joy` → 😂).
*
* Draws from the same bundled emojibase-data the reaction picker uses (served
* at ./emojibase by the `hermes:emojibase-assets` vite plugin — offline, no
* CDN). The index lazy-loads on the first `:` trigger, then every query is
* answered from memory, so `isCached` skips the debounce and loading state
* after that first load.
*
* A pick inserts the emoji CHARACTER as plain text — not a chip. Directive
* chips exist to carry machine-readable references the backend resolves
* (@file:, /skill); a picked emoji is just text, so it rides the formatter's
* `rawText` path and lands inline.
*/
interface EmojiEntry {
emoji: string
/** Primary shortcode, e.g. "joy". */
code: string
/** Every shortcode, tag, and label that should match a search. */
haystack: string[]
}
let indexPromise: Promise<EmojiEntry[]> | null = null
let indexLoaded = false
async function loadIndex(): Promise<EmojiEntry[]> {
const [dataRes, codesRes] = await Promise.all([
fetch('./emojibase/en/data.json'),
fetch('./emojibase/en/shortcodes/emojibase.json')
])
const data: { emoji: string; hexcode: string; label: string; tags?: string[] }[] = await dataRes.json()
const codes: Record<string, string | string[]> = await codesRes.json()
const entries: EmojiEntry[] = []
for (const item of data) {
const raw = codes[item.hexcode]
if (!raw) {
continue
}
const shortcodes = Array.isArray(raw) ? raw : [raw]
entries.push({
emoji: item.emoji,
code: shortcodes[0],
haystack: [...shortcodes, ...(item.tags ?? []), item.label.toLowerCase()]
})
}
indexLoaded = true
return entries
}
/** Prefix matches on shortcodes rank first, then tag/label substring hits. */
async function searchEmoji(query: string, limit = 8): Promise<EmojiEntry[]> {
const index = await (indexPromise ??= loadIndex())
const q = query.toLowerCase()
const prefix: EmojiEntry[] = []
const loose: EmojiEntry[] = []
for (const entry of index) {
if (entry.code.startsWith(q) || entry.haystack.some(h => h.startsWith(q))) {
prefix.push(entry)
} else if (entry.haystack.some(h => h.includes(q))) {
loose.push(entry)
}
if (prefix.length >= limit) {
break
}
}
return [...prefix, ...loose].slice(0, limit)
}
export function useEmojiCompletions() {
const fetcher = useCallback(async (query: string): Promise<CompletionPayload> => {
const entries = await searchEmoji(query)
return {
query,
items: entries.map(entry => ({
text: entry.emoji,
display: `${entry.emoji} :${entry.code}:`,
meta: ''
}))
}
}, [])
const toItem = useCallback(
(entry: CompletionEntry, index: number) => ({
id: `${entry.text}|${index}`,
type: 'emoji',
label: typeof entry.display === 'string' ? entry.display : entry.text,
metadata: {
display: typeof entry.display === 'string' ? entry.display : entry.text,
// The formatter's serialize() returns rawText verbatim → the emoji
// character lands as plain inline text, no chip.
rawText: entry.text,
meta: '',
group: '',
action: ''
}
}),
[]
)
return useLiveCompletionAdapter({
enabled: true,
fetcher,
isCached: () => indexLoaded,
toItem
})
}
@@ -0,0 +1,157 @@
import type { Unstable_TriggerAdapter, Unstable_TriggerItem } from '@assistant-ui/core'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
export interface CompletionEntry {
text: string
display?: unknown
meta?: unknown
/** From `complete.slash`: registry command vs skill. The popover groups on
* this instead of re-deriving kind from the desktop command table. */
kind?: string
/** Optional section label (e.g. "Commands", "Skills"). The popover renders a
* header whenever this changes between consecutive items, so the fetcher must
* emit entries already grouped contiguously. */
group?: string
/** Optional completion-action id. When set, picking the item runs that action
* (e.g. opening an overlay) instead of inserting a chip + waiting for submit. */
action?: string
}
export interface CompletionPayload {
items: CompletionEntry[]
query: string
}
const EMPTY_QUERY = '\u0000'
export function useLiveCompletionAdapter(options: {
enabled: boolean
debounceMs?: number
fetcher: (query: string) => Promise<CompletionPayload>
/** True when `fetcher` will answer this query from cache. Such a query skips
* both the debounce and the loading state — the debounce exists to avoid a
* request per keystroke, and a spinner over an answer we already hold reads
* as latency the user isn't actually paying. */
isCached?: (query: string) => boolean
/** Bump to declare the held answer stale. Without it a popover left open on
* an unchanged query would keep serving what it fetched before the source
* changed, because the adapter de-dupes on the query alone. */
epoch?: number
toItem: (entry: CompletionEntry, index: number) => Unstable_TriggerItem
}): { adapter: Unstable_TriggerAdapter; loading: boolean } {
const { enabled, debounceMs = 60, epoch = 0, fetcher, isCached, toItem } = options
const [state, setState] = useState<{ query: string; items: Unstable_TriggerItem[] }>({
query: EMPTY_QUERY,
items: []
})
const [loading, setLoading] = useState(false)
const tokenRef = useRef(0)
const timerRef = useRef<number | null>(null)
const pendingQueryRef = useRef<string | null>(null)
const cancelTimer = useCallback(() => {
if (timerRef.current !== null) {
window.clearTimeout(timerRef.current)
timerRef.current = null
}
}, [])
useEffect(() => () => cancelTimer(), [cancelTimer])
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
useEffect(() => {
if (enabled) {
return
}
cancelTimer()
pendingQueryRef.current = null
tokenRef.current += 1
setLoading(false)
setState({ query: EMPTY_QUERY, items: [] })
}, [cancelTimer, enabled])
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
useEffect(() => {
// Invalidate by forgetting which query the held items answer, so the next
// search() re-fetches. The items themselves stay until the new answer
// lands — an open popover must not blink empty on a background refresh.
// On mount this is already the state, so the first run is a no-op.
pendingQueryRef.current = null
setState(current => (current.query === EMPTY_QUERY ? current : { ...current, query: EMPTY_QUERY }))
}, [epoch])
const scheduleFetch = useCallback(
(query: string) => {
if (!enabled) {
return
}
if (pendingQueryRef.current === query) {
return
}
pendingQueryRef.current = query
cancelTimer()
const token = ++tokenRef.current
const cached = isCached?.(query) ?? false
if (!cached) {
setLoading(true)
}
const run = () => {
timerRef.current = null
fetcher(query)
.then(payload => {
if (token !== tokenRef.current) {
return
}
setState({
query: payload.query,
items: payload.items.map((entry, index) => toItem(entry, index))
})
})
.catch(() => {
if (token !== tokenRef.current) {
return
}
setState({ query, items: [] })
})
.finally(() => {
if (token === tokenRef.current) {
setLoading(false)
}
})
}
// A cached answer resolves in a microtask, so debouncing it would only
// add a frame of empty popover on every keystroke.
cached ? run() : (timerRef.current = window.setTimeout(run, debounceMs))
},
[cancelTimer, debounceMs, enabled, fetcher, isCached, toItem]
)
const adapter = useMemo<Unstable_TriggerAdapter>(
() => ({
categories: () => [],
categoryItems: () => [],
search: (query: string) => {
if (query !== state.query) {
scheduleFetch(query)
}
return state.items
}
}),
[scheduleFetch, state]
)
return { adapter, loading }
}
@@ -0,0 +1,295 @@
import { useEffect, useRef, useState } from 'react'
type BrowserAudioContext = typeof AudioContext
export interface MicRecorderOptions {
onLevel?: (level: number) => void
onError?: (error: Error) => void
onSilence?: () => void
silenceLevel?: number
silenceMs?: number
idleSilenceMs?: number
}
export interface MicRecording {
audio: Blob
durationMs: number
heardSpeech: boolean
}
export interface MicRecorderErrorCopy {
microphoneAccessDenied: string
microphoneConstraintsUnsupported: string
microphoneInUse: string
microphonePermissionDenied: string
microphoneStartFailed: string
microphoneUnsupported: string
noMicrophone: string
}
interface MicRecorderHandle {
start: (options?: MicRecorderOptions) => Promise<void>
stop: () => Promise<MicRecording | null>
cancel: () => void
}
function micError(error: unknown, copy: MicRecorderErrorCopy): Error {
const name = error instanceof DOMException ? error.name : ''
if (name === 'NotAllowedError' || name === 'SecurityError') {
return new Error(copy.microphonePermissionDenied)
}
if (name === 'NotFoundError' || name === 'DevicesNotFoundError') {
return new Error(copy.noMicrophone)
}
if (name === 'NotReadableError' || name === 'TrackStartError') {
return new Error(copy.microphoneInUse)
}
if (name === 'OverconstrainedError') {
return new Error(copy.microphoneConstraintsUnsupported)
}
if (error instanceof Error) {
return error
}
return new Error(copy.microphoneStartFailed)
}
export function useMicRecorder(copy: MicRecorderErrorCopy): {
handle: MicRecorderHandle
level: number
recording: boolean
} {
const [level, setLevel] = useState(0)
const [recording, setRecording] = useState(false)
const recorderRef = useRef<MediaRecorder | null>(null)
const streamRef = useRef<MediaStream | null>(null)
const chunksRef = useRef<Blob[]>([])
const audioContextRef = useRef<AudioContext | null>(null)
const animationRef = useRef<number | null>(null)
const startedAtRef = useRef(0)
const heardSpeechRef = useRef(false)
const silenceTriggeredRef = useRef(false)
const silenceStartedAtRef = useRef<number | null>(null)
const stopResolverRef = useRef<((recording: MicRecording | null) => void) | null>(null)
const cleanup = () => {
if (animationRef.current) {
window.cancelAnimationFrame(animationRef.current)
animationRef.current = null
}
void audioContextRef.current?.close()
audioContextRef.current = null
streamRef.current?.getTracks().forEach(track => track.stop())
streamRef.current = null
recorderRef.current = null
setLevel(0)
setRecording(false)
silenceTriggeredRef.current = false
}
useEffect(() => () => cleanup(), [])
const startMeter = (stream: MediaStream, options: MicRecorderOptions) => {
const audioWindow = window as Window & { webkitAudioContext?: BrowserAudioContext }
const AudioContextCtor = window.AudioContext || audioWindow.webkitAudioContext
if (!AudioContextCtor) {
return
}
try {
const audioContext = new AudioContextCtor()
const analyser = audioContext.createAnalyser()
const source = audioContext.createMediaStreamSource(stream)
analyser.fftSize = 256
const data = new Uint8Array(analyser.fftSize)
source.connect(analyser)
audioContextRef.current = audioContext
const tick = () => {
analyser.getByteTimeDomainData(data)
let sum = 0
for (const value of data) {
const centered = value - 128
sum += centered * centered
}
const rms = Math.sqrt(sum / data.length)
const normalized = Math.min(1, rms / 42)
const now = Date.now()
setLevel(normalized)
options.onLevel?.(normalized)
const speechThreshold = options.silenceLevel ?? 0
const silenceMs = options.silenceMs ?? 0
const idleSilenceMs = options.idleSilenceMs ?? 0
if (speechThreshold > 0 && options.onSilence && !silenceTriggeredRef.current) {
if (normalized >= speechThreshold) {
heardSpeechRef.current = true
silenceStartedAtRef.current = null
} else if (heardSpeechRef.current && silenceMs > 0) {
silenceStartedAtRef.current ??= now
if (now - silenceStartedAtRef.current >= silenceMs) {
silenceTriggeredRef.current = true
options.onSilence()
return
}
} else if (!heardSpeechRef.current && idleSilenceMs > 0 && now - startedAtRef.current >= idleSilenceMs) {
silenceTriggeredRef.current = true
options.onSilence()
return
}
}
animationRef.current = window.requestAnimationFrame(tick)
}
tick()
} catch {
setLevel(0)
}
}
const start: MicRecorderHandle['start'] = async (options = {}) => {
if (recorderRef.current) {
return
}
if (!navigator.mediaDevices?.getUserMedia || typeof MediaRecorder === 'undefined') {
throw new Error(copy.microphoneUnsupported)
}
const permitted = await window.hermesDesktop?.requestMicrophoneAccess?.()
if (permitted === false) {
throw new Error(copy.microphoneAccessDenied)
}
let stream: MediaStream
try {
stream = await navigator.mediaDevices.getUserMedia({
audio: { echoCancellation: true, noiseSuppression: true }
})
} catch (error) {
throw micError(error, copy)
}
const mimeType =
['audio/webm;codecs=opus', 'audio/webm', 'audio/mp4', 'audio/ogg;codecs=opus', 'audio/ogg', 'audio/wav'].find(
type => MediaRecorder.isTypeSupported(type)
) ?? ''
let recorder: MediaRecorder
try {
recorder = new MediaRecorder(stream, mimeType ? { mimeType } : undefined)
} catch (error) {
stream.getTracks().forEach(track => track.stop())
throw micError(error, copy)
}
chunksRef.current = []
streamRef.current = stream
recorderRef.current = recorder
heardSpeechRef.current = false
silenceTriggeredRef.current = false
silenceStartedAtRef.current = null
startedAtRef.current = Date.now()
recorder.ondataavailable = event => {
if (event.data.size > 0) {
chunksRef.current.push(event.data)
}
}
recorder.onstop = () => {
const chunks = chunksRef.current
const recordingType = recorder.mimeType || mimeType || 'audio/webm'
const durationMs = Date.now() - startedAtRef.current
const heardSpeech = heardSpeechRef.current
chunksRef.current = []
cleanup()
const resolver = stopResolverRef.current
stopResolverRef.current = null
if (!chunks.length) {
resolver?.(null)
return
}
resolver?.({
audio: new Blob(chunks, { type: recordingType }),
durationMs,
heardSpeech
})
}
recorder.onerror = event => {
const error = micError((event as Event & { error?: unknown }).error, copy)
const resolver = stopResolverRef.current
stopResolverRef.current = null
cleanup()
options.onError?.(error)
resolver?.(null)
}
recorder.start()
setRecording(true)
startMeter(stream, options)
}
const stop: MicRecorderHandle['stop'] = () =>
new Promise<MicRecording | null>(resolve => {
const recorder = recorderRef.current
if (!recorder || recorder.state === 'inactive') {
cleanup()
resolve(null)
return
}
stopResolverRef.current = resolve
recorder.stop()
})
const cancel: MicRecorderHandle['cancel'] = () => {
const recorder = recorderRef.current
const resolver = stopResolverRef.current
stopResolverRef.current = null
if (recorder && recorder.state !== 'inactive') {
recorder.ondataavailable = null
recorder.onerror = null
recorder.onstop = null
recorder.stop()
}
cleanup()
resolver?.(null)
}
const handle: MicRecorderHandle = { start, stop, cancel }
return { handle, level, recording }
}
@@ -0,0 +1,47 @@
import { useEffect } from 'react'
import { useSessionSlice } from '@/lib/use-session-slice'
import { setComposerActions } from '@/store/composer-actions'
import { $todosBySession } from '@/store/todos'
import { type ComposerMicroActionContext, useComposerMicroActionProviders } from '../contrib'
/**
* Resolve every registered micro-action provider for this session and publish
* the result to `$composerActionsBySession`, which the pill strip renders.
*
* Core registers nothing, so the strip stays empty until something contributes
* to `composer.microActions`. Providers are pure functions of the session
* context and the set is recomputed rather than mutated, so there are no
* ordering games between registrars and a provider that stops returning a
* badge withdraws it. One that throws is skipped, so a broken plugin loses
* only its own badge.
*/
export function useComposerMicroActions(sessionId: null | string, busy: boolean) {
const todos = useSessionSlice($todosBySession, sessionId)
const providers = useComposerMicroActionProviders()
useEffect(() => {
if (!sessionId) {
return
}
const ctx: ComposerMicroActionContext = { busy, sessionId, todos }
setComposerActions(
sessionId,
providers.flatMap(provider => {
try {
return provider.resolve(ctx) ?? []
} catch {
return []
}
})
)
}, [busy, providers, sessionId, todos])
// Withdraw on unmount / session switch ONLY. Clearing in the resolve effect's
// cleanup would publish an empty set before every republish — two store
// writes and two stack re-renders for what is usually a no-op.
useEffect(() => (sessionId ? () => setComposerActions(sessionId, []) : undefined), [sessionId])
}
@@ -0,0 +1,66 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { useRef } from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { useComposerPopoutGestures } from './use-popout-drag'
function GestureHarness({ onPopOut }: { onPopOut: () => void }) {
const composerRef = useRef<HTMLFormElement>(null)
const { onPointerDown } = useComposerPopoutGestures({
composerRef,
groupId: 'test-zone',
onDock: vi.fn(),
onPopOut,
poppedOut: false,
position: { bottom: 24, right: 24 }
})
return (
<form data-testid="composer" onPointerDown={onPointerDown} ref={composerRef}>
<div data-slot="composer-drag-region" data-testid="drag-region" />
<div data-slot="composer-surface" data-testid="surface">
<div contentEditable data-slot="composer-rich-input">
<span data-testid="editable-text">select me</span>
</div>
</div>
</form>
)
}
function dragUp(target: Element) {
fireEvent.pointerDown(target, { button: 0, clientX: 100, clientY: 100, pointerId: 7 })
fireEvent.pointerMove(window, { clientX: 100, clientY: 60, pointerId: 7 })
fireEvent.pointerUp(window, { clientX: 100, clientY: 60, pointerId: 7 })
}
afterEach(cleanup)
describe('useComposerPopoutGestures', () => {
it('never peels the composer out when a text-selection drag starts in the rich editor', () => {
const onPopOut = vi.fn()
render(<GestureHarness onPopOut={onPopOut} />)
dragUp(screen.getByTestId('editable-text'))
expect(onPopOut).not.toHaveBeenCalled()
})
it('does not arm a dock peel from the composer surface', () => {
const onPopOut = vi.fn()
render(<GestureHarness onPopOut={onPopOut} />)
dragUp(screen.getByTestId('surface'))
expect(onPopOut).not.toHaveBeenCalled()
})
it('still peels out from the dedicated drag region', () => {
const onPopOut = vi.fn()
render(<GestureHarness onPopOut={onPopOut} />)
dragUp(screen.getByTestId('drag-region'))
expect(onPopOut).toHaveBeenCalledOnce()
})
})
@@ -0,0 +1,380 @@
import { type PointerEvent as ReactPointerEvent, type RefObject, useCallback, useEffect, useRef, useState } from 'react'
import {
POPOUT_ESTIMATED_HEIGHT,
POPOUT_WIDTH_REM,
type PopoutBounds,
type PopoutPosition,
type PopoutSize,
readPopoutBounds,
setComposerPopoutPosition
} from '@/store/composer-popout'
// Floating surface long-press before it becomes draggable (the 5px platform drags
// instantly; this only covers grabbing the composer body itself).
const LONG_PRESS_MS = 360
const LONG_PRESS_MOVE_TOLERANCE = 10
// Upward drag distance from the docked composer that peels it off into a float.
const PEEL_OUT_PX = 16
const DOCK_ZONE_BOTTOM_PX = 72
// How close the composer's center must be to the viewport center (px) to count as
// "over the dock". Kept tight so the bottom-left/right corners stay free.
const DOCK_ZONE_CENTER_TOLERANCE_PX = 150
// Falloff distances over which dock proximity ramps from 1 (in-zone) down to 0.
const DOCK_VERTICAL_FALLOFF_PX = 260
const DOCK_HORIZONTAL_FALLOFF_PX = 220
interface PressState {
armed: boolean
mode: 'dock' | 'float'
pointerId: number
startBottom: number
startRight: number
startX: number
startY: number
}
interface ComposerPopoutGesturesOptions {
composerRef: RefObject<HTMLFormElement | null>
/** Layout zone this composer belongs to — the scope its float is stored under. */
groupId: string
onDock: () => void
onPopOut: () => void
poppedOut: boolean
position: PopoutPosition
}
function gestureTargetOk(target: EventTarget | null) {
if (!(target instanceof Element)) {
return false
}
return !target.closest(
'button, a, input, textarea, select, [contenteditable]:not([contenteditable="false"]), [data-slot="composer-rich-input"], [role="menuitem"], [data-radix-popper-content-wrapper]'
)
}
/** Docked composer only peels from its exposed grab ring. Pointer events from
* the editor and composer surface bubble through the root too, but they belong
* to text selection and controls, never to the pop-out gesture. */
function isDockDragPlatform(target: EventTarget | null) {
return target instanceof Element && Boolean(target.closest('[data-slot="composer-drag-region"]'))
}
/** Floating composer's 5px outer frame — grab here to drag without long-press. */
function isFloatDragPlatform(target: EventTarget | null) {
if (!(target instanceof Element)) {
return false
}
if (!target.closest('[data-slot="composer-root"][data-popped-out]')) {
return false
}
if (target.closest('[data-slot="composer-surface"], [data-slot="composer-rich-input"]')) {
return false
}
return gestureTargetOk(target)
}
/** 0 (far) → 1 (inside the dock zone). Drives both the dock glow and the
* release-to-dock test (which fires at proximity 1).
*
* Measured against THIS surface's area, not the window: the dock target is the
* docked composer, which sits at the bottom-center of its own chat surface. In
* a split (or any layout where the chat isn't the full window) the viewport's
* bottom-center is somewhere else entirely, so dragging onto the real dock
* never registered. */
function dockProximityOf(rect: DOMRect, area?: PopoutBounds) {
const a = area ?? { bottom: window.innerHeight, left: 0, right: window.innerWidth, top: 0 }
const horizontalDist = Math.abs(rect.left + rect.width / 2 - (a.left + a.right) / 2)
const verticalGap = a.bottom - DOCK_ZONE_BOTTOM_PX - rect.bottom
const v = verticalGap <= 0 ? 1 : Math.max(0, 1 - verticalGap / DOCK_VERTICAL_FALLOFF_PX)
const h =
horizontalDist <= DOCK_ZONE_CENTER_TOLERANCE_PX
? 1
: Math.max(0, 1 - (horizontalDist - DOCK_ZONE_CENTER_TOLERANCE_PX) / DOCK_HORIZONTAL_FALLOFF_PX)
return v * h
}
const clampOffset = (value: number, max: number) => Math.min(Math.max(0, value), max)
/** Fixed-position composer uses bottom/right insets; keep the grab point under the pointer. */
function popoutPositionUnderPointer(
clientX: number,
clientY: number,
grabX: number,
grabY: number,
boxWidth: number,
boxHeight: number
): PopoutPosition {
return {
bottom: window.innerHeight - clientY + grabY - boxHeight,
right: window.innerWidth - clientX + grabX - boxWidth
}
}
/**
* Gesture pop-out / dock for the composer — fully gestural, no hold-to-toggle.
*
* Docked: drag the composer upward (off the dock) to peel it out into a float,
* then keep dragging in the same motion.
* Floating: drag the 5px frame to move instantly, or long-press the body then
* drag; release over the bottom-center dock band to snap back in.
*/
export function useComposerPopoutGestures({
composerRef,
groupId,
onDock,
onPopOut,
poppedOut,
position
}: ComposerPopoutGesturesOptions) {
const [dragging, setDragging] = useState(false)
const [dockProximity, setDockProximity] = useState(0)
const stateRef = useRef<PressState | null>(null)
const timerRef = useRef<number | null>(null)
const liveRef = useRef(position)
liveRef.current = position
const onPopOutRef = useRef(onPopOut)
onPopOutRef.current = onPopOut
const clearTimer = useCallback(() => {
if (timerRef.current !== null) {
window.clearTimeout(timerRef.current)
timerRef.current = null
}
}, [])
const resetGesture = useCallback(() => {
clearTimer()
stateRef.current = null
setDragging(false)
setDockProximity(0)
}, [clearTimer])
const beginFloatDrag = useCallback(
(state: PressState, clientX: number, clientY: number, next: PopoutPosition, size?: PopoutSize) => {
clearTimer()
const clamped = setComposerPopoutPosition(groupId, next, {
area: readPopoutBounds(composerRef.current),
size
})
liveRef.current = clamped
state.mode = 'float'
state.armed = true
state.startBottom = clamped.bottom
state.startRight = clamped.right
state.startX = clientX
state.startY = clientY
setDragging(true)
},
[clearTimer, composerRef, groupId]
)
const peelOffFromDock = useCallback(
(state: PressState, clientX: number, clientY: number) => {
const composer = composerRef.current
if (!composer) {
return
}
const rem = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16
const rect = composer.getBoundingClientRect()
const boxWidth = POPOUT_WIDTH_REM * rem
const boxHeight = POPOUT_ESTIMATED_HEIGHT
const grabX = clampOffset(state.startX - rect.left, boxWidth)
const grabY = clampOffset(state.startY - rect.top, boxHeight)
const next = popoutPositionUnderPointer(clientX, clientY, grabX, grabY, boxWidth, boxHeight)
beginFloatDrag(state, clientX, clientY, next, { height: boxHeight, width: boxWidth })
onPopOutRef.current()
},
[beginFloatDrag, composerRef]
)
const onPointerDown = useCallback(
(event: ReactPointerEvent<HTMLElement>) => {
if (event.button !== 0 || !gestureTargetOk(event.target)) {
return
}
// Floating: grabbing the 5px platform drags immediately.
if (poppedOut && isFloatDragPlatform(event.target)) {
stateRef.current = {
armed: true,
mode: 'float',
pointerId: event.pointerId,
startBottom: liveRef.current.bottom,
startRight: liveRef.current.right,
startX: event.clientX,
startY: event.clientY
}
setDragging(true)
return
}
if (!poppedOut && !isDockDragPlatform(event.target)) {
return
}
stateRef.current = {
armed: false,
mode: poppedOut ? 'float' : 'dock',
pointerId: event.pointerId,
startBottom: liveRef.current.bottom,
startRight: liveRef.current.right,
startX: event.clientX,
startY: event.clientY
}
clearTimer()
// Docked has NO timer — pop-out is purely the upward peel gesture (handled
// in pointermove). Floating arms a long-press to drag the body.
if (poppedOut) {
timerRef.current = window.setTimeout(() => {
const state = stateRef.current
if (!state || state.armed) {
return
}
state.armed = true
setDragging(true)
}, LONG_PRESS_MS)
}
},
[clearTimer, poppedOut]
)
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
useEffect(() => {
// Coalesce drag updates to one per frame — pointermove can fire several times
// between paints on high-Hz mice, and each update re-renders + clamps.
let raf: number | null = null
let pending: { x: number; y: number } | null = null
const cancelRaf = () => {
if (raf !== null) {
cancelAnimationFrame(raf)
raf = null
}
}
const flush = () => {
raf = null
const state = stateRef.current
if (!state?.armed || state.mode !== 'float' || !pending) {
return
}
const composer = composerRef.current
const size = composer ? { height: composer.offsetHeight, width: composer.offsetWidth } : undefined
const area = readPopoutBounds(composer)
liveRef.current = setComposerPopoutPosition(
groupId,
{
bottom: state.startBottom - (pending.y - state.startY),
right: state.startRight - (pending.x - state.startX)
},
{ area, size }
)
if (composer) {
setDockProximity(dockProximityOf(composer.getBoundingClientRect(), area))
}
}
const handleMove = (event: PointerEvent) => {
const state = stateRef.current
if (!state || event.pointerId !== state.pointerId) {
return
}
// Pre-arm: cheap threshold checks run inline (no per-frame work yet).
if (!state.armed) {
const deltaX = event.clientX - state.startX
const deltaY = event.clientY - state.startY
if (state.mode === 'dock') {
// Peel off only on a clear upward drag — not a sideways/down wiggle.
if (-deltaY > PEEL_OUT_PX && -deltaY > Math.abs(deltaX)) {
peelOffFromDock(state, event.clientX, event.clientY)
} else if (Math.abs(deltaX) > PEEL_OUT_PX || deltaY > LONG_PRESS_MOVE_TOLERANCE) {
resetGesture()
}
} else if (Math.abs(deltaX) > LONG_PRESS_MOVE_TOLERANCE || Math.abs(deltaY) > LONG_PRESS_MOVE_TOLERANCE) {
// Float body long-press pending: movement cancels the hold.
resetGesture()
}
return
}
if (state.mode !== 'float') {
return
}
event.preventDefault()
pending = { x: event.clientX, y: event.clientY }
raf ??= requestAnimationFrame(flush)
}
const handleUp = (event: PointerEvent) => {
const state = stateRef.current
if (!state || event.pointerId !== state.pointerId) {
return
}
cancelRaf()
if (state.armed && state.mode === 'float') {
const composer = composerRef.current
const rect = composer?.getBoundingClientRect()
const area = readPopoutBounds(composer)
if (rect && dockProximityOf(rect, area) >= 1) {
onDock()
} else {
// Persist the resting position once, on release — never per move.
const size = composer ? { height: composer.offsetHeight, width: composer.offsetWidth } : undefined
setComposerPopoutPosition(groupId, liveRef.current, { area, persist: true, size })
}
}
resetGesture()
}
window.addEventListener('pointermove', handleMove)
window.addEventListener('pointerup', handleUp)
window.addEventListener('pointercancel', handleUp)
return () => {
cancelRaf()
window.removeEventListener('pointermove', handleMove)
window.removeEventListener('pointerup', handleUp)
window.removeEventListener('pointercancel', handleUp)
}
}, [composerRef, groupId, onDock, peelOffFromDock, resetGesture])
useEffect(() => clearTimer, [clearTimer])
return { dockProximity, dragging, onPointerDown }
}
@@ -0,0 +1,187 @@
import type { Unstable_TriggerItem } from '@assistant-ui/core'
import { act, cleanup, render } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { HermesGateway } from '@/hermes'
import { queryClient } from '@/lib/query-client'
import { invalidateSlashCompletions } from '@/lib/slash-completion-cache'
import { isSkillItem } from '../composer-utils'
import { useSlashCompletions } from './use-slash-completions'
const CATALOG = {
categories: [{ name: 'Session', pairs: [['/new', 'Start a new session']] }],
pairs: [
['/new', 'Start a new session'],
['/work', 'Kick off a task in a fresh worktree']
]
}
// A catalog shaped like a real install: a couple of skills the user lives in,
// a bundled one they have never opened, and one of their own they haven't
// either.
const RANKED_CATALOG = {
categories: [{ name: 'Session', pairs: [['/new', 'Start a new session']] }],
pairs: [
['/new', 'Start a new session'],
['/docx', 'Edit Word documents'],
['/research', 'Look it up before answering'],
['/research-paper-writing', 'Write an academic paper'],
['/work', 'Kick off a task in a fresh worktree']
],
skills: {
'/docx': { usage: 0, origin: 'local' },
'/research': { usage: 60, origin: 'local' },
'/research-paper-writing': { usage: 0, origin: 'bundled' },
'/work': { usage: 172, origin: 'local' }
}
}
const commandsOf = (items: readonly Unstable_TriggerItem[]) =>
items.map(item => (item.metadata as { command?: string })?.command)
function harness(gateway: HermesGateway) {
const api: { search?: (query: string) => readonly Unstable_TriggerItem[] } = {}
function Probe() {
const { adapter } = useSlashCompletions({ gateway })
api.search = adapter.search
return null
}
render(<Probe />)
return api as { search: (query: string) => readonly Unstable_TriggerItem[] }
}
/** Drive the adapter until its async fetch has settled into `search`'s result. */
async function completions(api: { search: (query: string) => readonly Unstable_TriggerItem[] }, query: string) {
await act(async () => {
api.search(query)
await Promise.resolve()
})
// The debounce is skipped only for cached queries; give the timer a beat.
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 120))
})
return api.search(query)
}
afterEach(() => {
cleanup()
queryClient.clear()
})
describe('useSlashCompletions', () => {
it('serves the bare-slash catalog from cache instead of re-requesting it', async () => {
const request = vi.fn().mockResolvedValue(CATALOG)
const api = harness({ request } as unknown as HermesGateway)
await completions(api, '')
expect(request).toHaveBeenCalledTimes(1)
// Reopening `/` must not hit the gateway again.
queryClient.setQueryData(['unrelated'], 1)
await completions(api, '')
expect(request).toHaveBeenCalledTimes(1)
// …until something that changes the command set invalidates it.
await act(async () => invalidateSlashCompletions())
await completions(api, '')
expect(request).toHaveBeenCalledTimes(2)
})
it('offers skill commands on a bare slash, not just built-ins', async () => {
const request = vi.fn().mockResolvedValue(CATALOG)
const api = harness({ request } as unknown as HermesGateway)
const items = await completions(api, '')
const work = items.find(item => (item.metadata as { command?: string })?.command === '/work')
expect((work?.metadata as { group?: string })?.group).toBe('Skills')
})
// A `/` typed mid-message is a reference dropped into prose, so the trigger
// filters the list to skills (use-composer-trigger). A bare mid-message `/`
// resolves to the same empty query as an opening `/`, so that filter runs
// over the catalog — which listed no skill-group rows at all, leaving the
// inline popover empty. Asserted through isSkillItem, the real predicate.
it('leaves only skills for a mid-message slash', async () => {
const request = vi.fn().mockResolvedValue(CATALOG)
const api = harness({ request } as unknown as HermesGateway)
const inline = (await completions(api, '')).filter(isSkillItem)
expect(inline.map(item => (item.metadata as { command?: string })?.command)).toEqual(['/work'])
})
// An alphabetical `/` menu buries the skills someone runs daily under the
// ones that shipped with Hermes and were never opened.
it('orders skills by use and hides never-used built-ins on a bare slash', async () => {
const request = vi.fn().mockResolvedValue(RANKED_CATALOG)
const api = harness({ request } as unknown as HermesGateway)
const skills = commandsOf((await completions(api, '')).filter(isSkillItem))
expect(skills).toEqual(['/work', '/research', '/docx'])
})
// Typing is a search, and a search that hides a match is broken. Order
// stays the backend's (fuzzy score, then usage) — a second client usage
// sort is what buried `/review` under skills.
it('keeps backend order on a typed query and does not hide matches', async () => {
const request = vi.fn().mockImplementation((method: string) =>
Promise.resolve(
method === 'commands.catalog'
? RANKED_CATALOG
: {
items: [
{
text: '/research-paper-writing',
display: '/research-paper-writing',
kind: 'skill',
meta: 'Write a paper'
},
{ text: '/research', display: '/research', kind: 'skill', meta: 'Look it up' }
]
}
)
)
const api = harness({ request } as unknown as HermesGateway)
expect(commandsOf(await completions(api, 'research'))).toEqual(['/research-paper-writing', '/research'])
})
it('keeps a registry command in Commands even when the desktop table has no row', async () => {
const request = vi.fn().mockImplementation((method: string) =>
Promise.resolve(
method === 'commands.catalog'
? CATALOG
: {
items: [
{ text: '/refine', display: '/refine', kind: 'command', meta: 'Review this conversation' },
{ text: '/docx', display: '/docx', kind: 'skill', meta: 'Edit Word documents' },
{ text: '/compress', display: '/compress', kind: 'command', meta: 'Compress context' }
]
}
)
)
const api = harness({ request } as unknown as HermesGateway)
const items = await completions(api, 're')
const groupOf = (command: string) =>
(items.find(item => (item.metadata as { command?: string })?.command === command)?.metadata as { group?: string })
?.group
expect(commandsOf(items)).toEqual(['/refine', '/compress', '/docx'])
expect(groupOf('/refine')).toBe('Commands')
expect(groupOf('/compress')).toBe('Commands')
expect(groupOf('/docx')).toBe('Skills')
})
})
@@ -0,0 +1,302 @@
import type { Unstable_TriggerAdapter, Unstable_TriggerItem } from '@assistant-ui/core'
import { useStore } from '@nanostores/react'
import { useCallback, useEffect } from 'react'
import type { HermesGateway } from '@/hermes'
import { sessionTitle } from '@/lib/chat-runtime'
import {
type CommandsCatalogLike,
desktopSkinSlashCompletions,
desktopSlashDescription,
type DesktopThemeCommandOption,
filterDesktopCommandsCatalog,
isDesktopSlashExtensionCommand,
isDesktopSlashSuggestion,
rankSkillCommands,
slashCompletionGroup
} from '@/lib/desktop-slash-commands'
import { $slashCompletionsEpoch, cachedSlashCompletion, hasCachedSlashCompletion } from '@/lib/slash-completion-cache'
import { normalize } from '@/lib/text'
import { $sessions } from '@/store/session'
import type { CompletionEntry, CompletionPayload } from './use-live-completion-adapter'
import { useLiveCompletionAdapter } from './use-live-completion-adapter'
interface SlashItemMetadata extends Record<string, string> {
command: string
display: string
meta: string
group: string
rawText: string
/** Completion-action id; empty for ordinary insert-a-chip completions. */
action: string
}
function textValue(value: unknown, fallback = ''): string {
if (typeof value === 'string') {
return value
}
if (Array.isArray(value)) {
return value
.map(part => (Array.isArray(part) ? String(part[1] ?? '') : typeof part === 'string' ? part : ''))
.join('')
.trim()
}
return fallback
}
function commandText(value: string): string {
return value.startsWith('/') ? value : `/${value}`
}
/** How many recent sessions to surface inline before the "Browse all…" entry. */
const SESSION_INLINE_LIMIT = 7
/** Live `/` completions backed by the gateway's `complete.slash` RPC. */
export function useSlashCompletions(options: {
gateway: HermesGateway | null
/** Desktop theme list — `/skin` is owned client-side, so its arg completions
* come from here, not the backend (whose skin list is CLI/TUI-only). */
skinThemes?: DesktopThemeCommandOption[]
activeSkin?: string
}): {
adapter: Unstable_TriggerAdapter
loading: boolean
} {
const { gateway, skinThemes, activeSkin } = options
const enabled = Boolean(gateway)
const epoch = useStore($slashCompletionsEpoch)
// Warm argument_mode before the first `/` so Space treats /review as text.
useEffect(() => {
if (!gateway) {
return
}
void cachedSlashCompletion('catalog', () => gateway.request<CommandsCatalogLike>('commands.catalog'))
.then(catalog => {
filterDesktopCommandsCatalog(catalog)
})
.catch(() => {
// Next keystroke retries; don't block the composer on a warm-up miss.
})
}, [gateway, epoch])
const fetcher = useCallback(
async (query: string): Promise<CompletionPayload> => {
if (!gateway) {
return { items: [], query }
}
const text = `/${query}`
// The desktop owns /skin entirely (client-side theme context). Surface its
// theme list inside this single popover instead of a bespoke one, and skip
// the backend skin completions (which describe CLI/TUI skins that don't
// apply here). Matches once we're past `/skin ` into the arg stage.
const skinArg = /^\/skin\s+(.*)$/is.exec(text)
if (skinArg && skinThemes) {
const items = desktopSkinSlashCompletions(skinThemes, activeSkin ?? '', skinArg[1] ?? '').map(entry => ({
text: entry.text,
display: entry.display,
meta: entry.meta,
group: 'Themes'
}))
return { items, query }
}
// /resume (and its aliases) completes recent sessions inline — the same
// client-side list the picker overlay shows — instead of the backend
// (whose /resume opens an interactive TUI picker we can't render here).
const sessionArg = /^\/(?:resume|sessions|switch)\s+(.*)$/is.exec(text)
if (sessionArg) {
const needle = normalize(sessionArg[1])
const matches = (
needle
? $sessions
.get()
.filter(
session =>
sessionTitle(session).toLowerCase().includes(needle) ||
(session.preview ?? '').toLowerCase().includes(needle) ||
session.id.toLowerCase().includes(needle)
)
: $sessions.get()
).slice(0, SESSION_INLINE_LIMIT)
const items: CompletionEntry[] = matches.map(session => ({
text: `/resume ${session.id}`,
display: sessionTitle(session),
meta: (session.preview ?? '').trim(),
group: 'Sessions'
}))
// Trailing "more" affordance (Cursor-style): picking it opens the full
// session picker overlay directly. `text` stays a bare `/resume` so that
// submitting it (Enter) still opens the overlay if the action is skipped.
items.push({
text: '/resume',
display: 'Browse all sessions…',
meta: '',
group: 'Sessions',
action: 'session-picker'
})
return { items, query }
}
try {
if (!query) {
const catalog = filterDesktopCommandsCatalog(
await cachedSlashCompletion('catalog', () => gateway.request<CommandsCatalogLike>('commands.catalog'))
)
// Prefer the categorized layout so the popover renders section headers
// (Session, Tools & Skills, ...). Fall back to the flat list when the
// backend didn't categorize.
const sections = catalog.categories?.length ? catalog.categories : [{ name: '', pairs: catalog.pairs ?? [] }]
const items = sections.flatMap<CompletionEntry>(section =>
section.pairs.map(([command, meta]) => ({
text: command,
display: command,
group: section.name || undefined,
meta
}))
)
// Skill commands reach us only through the flat `pairs` list — the
// backend categorizes registry commands but appends skills
// uncategorized, so the categorized layout alone drops every skill
// from the bare `/` list even though typing `/wo` offers them.
// Re-add the leftovers under one Skills header (which also gives them
// the skill pill accent and makes them offerable mid-message).
const categorized = new Set(items.map(item => item.text.toLowerCase()))
const skillRows: CompletionEntry[] = []
for (const [command, meta] of catalog.pairs ?? []) {
if (!categorized.has(command.toLowerCase()) && isDesktopSlashExtensionCommand(command)) {
skillRows.push({ text: command, display: command, group: 'Skills', meta })
}
}
// Browsing, not searching: rank the skills the user actually reaches
// for to the top and drop never-used built-ins entirely. Typing a
// query takes the other branch, where nothing is hidden.
items.push(...rankSkillCommands(skillRows, catalog.skills, { pruneUnusedBuiltins: true }))
return { items, query }
}
const result = await cachedSlashCompletion(`slash:${text.toLowerCase()}`, () =>
gateway.request<{ items?: CompletionEntry[]; replace_from?: number }>('complete.slash', { text })
)
// Arg-completion items (replace_from > 1) carry just the arg stub —
// e.g. complete.slash returns `{text: "alice"}` for `/personality alic`
// with replace_from = 14. Rewrite those entries so the popover inserts
// the full `/personality alice` token instead of stranding `/alice`.
const replaceFrom = typeof result.replace_from === 'number' ? result.replace_from : 1
const isArgCompletion = replaceFrom > 1
const prefix = isArgCompletion ? text.slice(0, replaceFrom) : ''
const decorated = (result.items ?? [])
.map(item => {
if (!isArgCompletion) {
return item
}
const argText = typeof item.text === 'string' ? item.text : ''
return { ...item, text: `${prefix}${argText}` }
})
.filter(item => isArgCompletion || isDesktopSlashSuggestion(item.text))
.map(item => ({
...item,
// Arg suggestions (e.g. `/handoff <platform>`) live under one
// header; otherwise split skills out from built-in commands.
// Kind comes from the backend — the desktop table is a visibility
// gate (`isDesktopSlashSuggestion`), not a classifier.
group: isArgCompletion ? 'Options' : slashCompletionGroup(item.text, item.kind),
// Arg items carry their own meta (the personality/toolset/platform
// blurb). Only command rows get the registry description — looking
// one up for `/personality none` would clobber it with the parent
// command's text.
meta: isArgCompletion ? textValue(item.meta) : desktopSlashDescription(item.text, textValue(item.meta))
}))
// Keep each group contiguous so headers render once: Commands before
// Skills (stable within a group, preserving backend relevance order).
// Do not re-sort skills by usage here — complete.slash already ranked
// by fuzzy score, then usage. A second usage pass buried exact name
// matches that the table had mis-filed as skills.
const groupOrder = ['Commands', 'Skills', 'Options']
if (isArgCompletion) {
return { items: decorated, query }
}
const items = [...decorated].sort(
(a, b) => groupOrder.indexOf(a.group ?? '') - groupOrder.indexOf(b.group ?? '')
)
return { items, query }
} catch {
return { items: [], query }
}
},
[gateway, skinThemes, activeSkin]
)
const toItem = useCallback((entry: CompletionEntry, index: number): Unstable_TriggerItem => {
const command = commandText(entry.text)
const display = textValue(entry.display, commandText(entry.text))
const meta = textValue(entry.meta)
const metadata: SlashItemMetadata = {
command,
display,
meta,
group: textValue(entry.group),
action: textValue(entry.action),
// Provide rawText so hermesDirectiveFormatter.serialize uses the
// direct-insertion path instead of the legacy @type:id fallback.
// Without this, the item.id (which includes a "|index" suffix for
// trigger-adapter uniqueness) leaks into the serialized chip text
// and the submitted command.
rawText: command
}
return {
id: `${entry.text}|${index}`,
type: 'slash',
label: display.startsWith('/') ? display.slice(1) : display,
...(meta ? { description: meta } : {}),
metadata
}
}, [])
// Mirrors the fetcher's branching: the `/skin` and `/resume` arg stages are
// answered from client-side state, so they never wait on the network; every
// other query is served from the completion cache when it's still warm.
const isCached = useCallback(
(query: string) => {
const text = `/${query}`
if ((skinThemes && /^\/skin\s+/is.test(text)) || /^\/(?:resume|sessions|switch)\s+/is.test(text)) {
return true
}
return hasCachedSlashCompletion(query ? `slash:${text.toLowerCase()}` : 'catalog')
},
[skinThemes]
)
return useLiveCompletionAdapter({ enabled, epoch, fetcher, isCached, toItem })
}
@@ -0,0 +1,44 @@
import { useSyncExternalStore } from 'react'
import { $composerActionsBySession } from '@/store/composer-actions'
import { $statusItemsBySession } from '@/store/composer-status'
import { $previewStatusBySession } from '@/store/preview-status'
/** Structural view of the three per-session feeds — they hold different item
* types, and all this hook needs from each is "does this key have rows". */
interface PresenceFeed {
get(): Record<string, undefined | unknown[]>
listen(listener: () => void): () => void
}
const FEEDS: PresenceFeed[] = [$statusItemsBySession, $composerActionsBySession, $previewStatusBySession]
const subscribe = (onChange: () => void) => {
const offs = FEEDS.map(feed => feed.listen(onChange))
return () => {
for (const off of offs) {
off()
}
}
}
/**
* Whether a session has any status items, micro actions, or previews, as a
* coarse *edge*: the boolean only flips when the stack appears/disappears.
* ChatBar uses it to toggle a styling data-attr — subscribing to the whole
* `$statusItemsBySession` (a `computed` that rebuilds the entire map) /
* `$previewStatusBySession` maps re-rendered the ~1.4k ChatBar on every
* per-item mutation (a subagent tick, a 5s background poll) and on churn in
* OTHER sessions. The boolean snapshot bails out of all of that, re-rendering
* only on the actual show/hide transition.
*/
export function useSessionStatusPresence(sessionId: string | null): boolean {
return useSyncExternalStore(subscribe, () => {
if (!sessionId) {
return false
}
return FEEDS.some(feed => (feed.get()[sessionId]?.length ?? 0) > 0)
})
}
@@ -0,0 +1,258 @@
import { act, cleanup, renderHook, waitFor } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { $voicePlayback } from '@/store/voice-playback'
import { useVoiceConversation } from './use-voice-conversation'
const mocks = vi.hoisted(() => {
let deferStreamStart = false
let onSilence: null | (() => void) = null
let resolveStreamStart: null | (() => void) = null
let resolveSpeech: null | ((outcome: 'done' | 'fallback') => void) = null
let streamAvailable = true
const stopVoicePlayback = vi.fn(() => {
const current = $voicePlayback.get()
$voicePlayback.set({ ...current, sequence: current.sequence + 1, status: 'idle' })
})
const playSpeechText = vi.fn(() => {
stopVoicePlayback()
return Promise.resolve(true)
})
const handle = {
cancel: vi.fn(),
start: vi.fn(async (options: { onSilence: () => void }) => {
onSilence = options.onSilence
}),
stop: vi.fn(async () => ({
audio: new Blob(['voice'], { type: 'audio/webm' }),
heardSpeech: true
}))
}
return {
continueStreamStart() {
resolveStreamStart?.()
resolveStreamStart = null
},
deferStreamStart() {
deferStreamStart = true
},
finishSpeech(outcome: 'done' | 'fallback') {
resolveSpeech?.(outcome)
},
handle,
playSpeechText,
resetSpeechMocks() {
deferStreamStart = false
resolveStreamStart = null
resolveSpeech = null
streamAvailable = true
},
startSpeechStream: vi.fn(async () => {
if (deferStreamStart) {
await new Promise<void>(resolve => {
resolveStreamStart = resolve
})
}
if (!streamAvailable) {
return null
}
const current = $voicePlayback.get()
$voicePlayback.set({ ...current, sequence: current.sequence + 1, status: 'preparing' })
return {
append: vi.fn(),
done: new Promise<'done' | 'fallback'>(resolve => {
resolveSpeech = resolve
}),
finish: vi.fn()
}
}),
stopVoicePlayback,
triggerSilence() {
onSilence?.()
},
useFallbackSpeech() {
streamAvailable = false
}
}
})
vi.mock('./use-mic-recorder', () => ({
useMicRecorder: () => ({ handle: mocks.handle, level: 0 })
}))
vi.mock('@/lib/voice-barge-in', () => ({
monitorSpeechDuringPlayback: () => vi.fn()
}))
vi.mock('@/lib/voice-playback', () => ({
markVoicePlaybackInterrupted: vi.fn(),
playSpeechText: mocks.playSpeechText,
startSpeechStream: mocks.startSpeechStream,
stopVoicePlayback: mocks.stopVoicePlayback
}))
vi.mock('@/lib/thinking-sound', () => ({
startThinkingSound: vi.fn(),
stopThinkingSound: vi.fn()
}))
vi.mock('@/store/notifications', () => ({
notify: vi.fn(),
notifyError: vi.fn()
}))
vi.mock('@/i18n', () => ({
useI18n: () => ({
t: {
notifications: {
voice: {
configureSpeechToText: '',
couldNotStartSession: '',
microphoneFailed: '',
playbackFailed: '',
transcriptionFailed: '',
unavailable: ''
}
}
}
})
}))
function renderRearmConversation(responseId: string, responseText: string) {
let response: null | { id: string; pending: boolean; text: string } = null
return renderHook(
({ enabled }) =>
useVoiceConversation({
busy: false,
consumePendingResponse: vi.fn(),
enabled,
onSubmit: async () => {
response = { id: responseId, pending: false, text: responseText }
},
onTranscribeAudio: async () => 'Hello',
pendingResponse: () => response
}),
{ initialProps: { enabled: false } }
)
}
async function beginReply(hook: ReturnType<typeof renderRearmConversation>) {
hook.rerender({ enabled: true })
await waitFor(() => expect(mocks.handle.start).toHaveBeenCalledTimes(1))
await act(async () => {
mocks.triggerSilence()
})
}
describe('useVoiceConversation playback rearm', () => {
afterEach(() => {
cleanup()
vi.clearAllMocks()
mocks.resetSpeechMocks()
$voicePlayback.set({
audioElement: null,
messageId: null,
sequence: 0,
source: null,
status: 'idle'
})
})
it('re-arms the microphone after normal streaming playback completes', async () => {
$voicePlayback.set({
audioElement: null,
messageId: null,
sequence: 7,
source: null,
status: 'idle'
})
const hook = renderRearmConversation('reply-1', 'Hello back')
await beginReply(hook)
await waitFor(() => expect(mocks.startSpeechStream).toHaveBeenCalled())
expect($voicePlayback.get().sequence).toBeGreaterThan(7)
await act(async () => {
mocks.finishSpeech('done')
})
await waitFor(() => expect(mocks.handle.start).toHaveBeenCalledTimes(2))
expect(hook.result.current.status).toBe('listening')
})
it('honors Stop while streaming playback is still preparing', async () => {
mocks.deferStreamStart()
const hook = renderRearmConversation('reply-preparing', 'Do not play this')
await beginReply(hook)
await waitFor(() => expect(mocks.startSpeechStream).toHaveBeenCalled())
mocks.stopVoicePlayback()
await act(async () => {
mocks.continueStreamStart()
})
await waitFor(() => expect(hook.result.current.status).toBe('idle'))
expect(mocks.stopVoicePlayback).toHaveBeenCalledTimes(2)
expect(mocks.handle.start).toHaveBeenCalledTimes(1)
})
it('does not start fallback playback after Stop during stream discovery', async () => {
mocks.deferStreamStart()
mocks.useFallbackSpeech()
const hook = renderRearmConversation('reply-no-stream', 'Do not fall back')
await beginReply(hook)
await waitFor(() => expect(mocks.startSpeechStream).toHaveBeenCalled())
mocks.stopVoicePlayback()
await act(async () => {
mocks.continueStreamStart()
})
await waitFor(() => expect(hook.result.current.status).toBe('idle'))
expect(mocks.playSpeechText).not.toHaveBeenCalled()
expect(mocks.handle.start).toHaveBeenCalledTimes(1)
})
it('does not re-arm after an external Stop during streaming playback', async () => {
const hook = renderRearmConversation('reply-stopped', 'Playing now')
await beginReply(hook)
await waitFor(() => expect(mocks.startSpeechStream).toHaveBeenCalled())
mocks.stopVoicePlayback()
await act(async () => {
mocks.finishSpeech('done')
})
await waitFor(() => expect(hook.result.current.status).toBe('idle'))
expect(mocks.handle.start).toHaveBeenCalledTimes(1)
})
it('re-arms the microphone after normal fallback playback completes', async () => {
mocks.useFallbackSpeech()
const hook = renderRearmConversation('reply-fallback', 'Fallback reply')
await beginReply(hook)
await waitFor(() =>
expect(mocks.playSpeechText).toHaveBeenCalledWith('Fallback reply', {
source: 'voice-conversation'
})
)
await waitFor(() => expect(mocks.handle.start).toHaveBeenCalledTimes(2))
expect(hook.result.current.status).toBe('listening')
})
})
@@ -0,0 +1,266 @@
import { act, cleanup, renderHook, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { BargeMonitorCallbacks } from '@/lib/voice-barge-in'
import type { MicRecording } from './use-mic-recorder'
import { useVoiceConversation } from './use-voice-conversation'
// The full-duplex contract: the barge monitor is live across the WHOLE agent
// turn — generation (thinking) and playback (speaking) — so speaking over the
// model interrupts it mid-generation instead of the mic being deaf until TTS
// starts (the Windows report: interruption "never works" because the deaf
// window covered generation, and playback bleed made the old monitor's
// trigger unreachable).
const monitorCalls: BargeMonitorCallbacks[] = []
const stopMonitor = vi.fn()
vi.mock('@/lib/voice-barge-in', () => ({
monitorSpeechDuringPlayback: (callbacks: BargeMonitorCallbacks) => {
monitorCalls.push(callbacks)
return stopMonitor
}
}))
const markVoicePlaybackInterrupted = vi.fn()
const stopVoicePlayback = vi.fn()
vi.mock('@/lib/voice-playback', () => ({
markVoicePlaybackInterrupted: () => markVoicePlaybackInterrupted(),
playSpeechText: vi.fn(async () => true),
startSpeechStream: vi.fn(async () => null),
stopVoicePlayback: () => stopVoicePlayback()
}))
vi.mock('@/lib/thinking-sound', () => ({
startThinkingSound: vi.fn(),
stopThinkingSound: vi.fn()
}))
const micHandle = {
cancel: vi.fn(),
start: vi.fn(async () => undefined),
stop: vi.fn<() => Promise<MicRecording | null>>(async () => null)
}
vi.mock('./use-mic-recorder', () => ({
useMicRecorder: () => ({ handle: micHandle, level: 0, recording: false })
}))
vi.mock('@/i18n', () => ({
useI18n: () => ({
t: {
notifications: {
voice: {
configureSpeechToText: 'configure STT',
couldNotStartSession: 'could not start',
microphoneFailed: 'mic failed',
playbackFailed: 'playback failed',
transcriptionFailed: 'transcription failed',
unavailable: 'unavailable'
}
}
}
})
}))
vi.mock('@/store/notifications', () => ({
notify: vi.fn(),
notifyError: vi.fn()
}))
interface HookProps {
busy: boolean
}
function renderConversation(overrides: { onInterrupt?: () => void; transcript?: string } = {}) {
const onInterrupt = overrides.onInterrupt ?? vi.fn()
// Mirrors the real app: submitting a turn makes the agent busy.
const onBusyChange: { current: (busy: boolean) => void } = { current: () => undefined }
const onSubmit = vi.fn(async () => {
onBusyChange.current(true)
})
const onStopWord = vi.fn()
// First transcription is the turn that starts the conversation; subsequent
// ones are barge captures (the overridable transcript).
let transcriptions = 0
const onTranscribeAudio = vi.fn(async () =>
transcriptions++ === 0 ? 'kick off the task' : (overrides.transcript ?? 'and another thing')
)
const hook = renderHook(
({ busy }: HookProps) =>
useVoiceConversation({
busy,
consumePendingResponse: vi.fn(),
enabled: true,
onInterrupt,
onStopWord,
onSubmit,
onTranscribeAudio,
pendingResponse: () => null
}),
{ initialProps: { busy: false } }
)
onBusyChange.current = busy => hook.rerender({ busy })
return { hook, onInterrupt, onStopWord, onSubmit, onTranscribeAudio }
}
/** Drive the hook into the generation phase (turn submitted, model working). */
async function enterThinking(hook: ReturnType<typeof renderConversation>['hook']) {
await act(async () => {
await hook.result.current.start()
})
await waitFor(() => expect(hook.result.current.status).toBe('listening'))
micHandle.stop.mockResolvedValueOnce({
audio: new Blob(['q'], { type: 'audio/webm' }),
durationMs: 900,
heardSpeech: true
})
await act(async () => {
hook.result.current.stopTurn()
})
await waitFor(() => expect(hook.result.current.status).toBe('thinking'))
}
describe('useVoiceConversation full-duplex barge-in', () => {
beforeEach(() => {
monitorCalls.length = 0
vi.clearAllMocks()
micHandle.start.mockResolvedValue(undefined)
micHandle.stop.mockResolvedValue(null)
})
afterEach(cleanup)
it('arms the barge monitor during generation (before any reply audio exists)', async () => {
const { hook } = renderConversation()
await act(async () => {
await hook.result.current.start()
})
await enterThinking(hook)
await waitFor(() => expect(hook.result.current.status).toBe('thinking'))
// busy=true + thinking → the full-duplex monitor must be live.
await waitFor(() => expect(monitorCalls.length).toBeGreaterThan(0))
})
it('interrupts the in-flight turn when speech trips mid-generation', async () => {
const { hook, onInterrupt } = renderConversation()
await act(async () => {
await hook.result.current.start()
})
await enterThinking(hook)
await waitFor(() => expect(monitorCalls.length).toBeGreaterThan(0))
act(() => {
monitorCalls.at(-1)?.onSpeech()
})
expect(onInterrupt).toHaveBeenCalledTimes(1)
expect(markVoicePlaybackInterrupted).toHaveBeenCalled()
expect(stopVoicePlayback).toHaveBeenCalled()
})
it('submits the captured interruption once the interrupt settles (busy clears)', async () => {
const { hook, onSubmit } = renderConversation({ transcript: 'no, do it differently' })
await act(async () => {
await hook.result.current.start()
})
await enterThinking(hook)
await waitFor(() => expect(monitorCalls.length).toBeGreaterThan(0))
const monitor = monitorCalls.at(-1)
act(() => {
monitor?.onSpeech()
})
// Interrupt lands → the turn ends → busy flips false.
hook.rerender({ busy: false })
await act(async () => {
monitor?.onUtterance?.(new Blob(['x'], { type: 'audio/webm' }))
})
await waitFor(() => expect(onSubmit).toHaveBeenCalledWith('no, do it differently'))
})
it('does not interrupt when speech trips during playback (turn already done)', async () => {
const { hook, onInterrupt } = renderConversation()
await act(async () => {
await hook.result.current.start()
})
await enterThinking(hook)
await waitFor(() => expect(monitorCalls.length).toBeGreaterThan(0))
// Turn finished; playback phase.
hook.rerender({ busy: false })
act(() => {
monitorCalls.at(-1)?.onSpeech()
})
expect(onInterrupt).not.toHaveBeenCalled()
expect(stopVoicePlayback).toHaveBeenCalled()
})
it('a spoken stop command in the barge capture ends the conversation instead of submitting', async () => {
const { hook, onStopWord, onSubmit } = renderConversation({ transcript: 'stop' })
await act(async () => {
await hook.result.current.start()
})
await enterThinking(hook)
await waitFor(() => expect(monitorCalls.length).toBeGreaterThan(0))
const monitor = monitorCalls.at(-1)
act(() => {
monitor?.onSpeech()
})
hook.rerender({ busy: false })
await act(async () => {
monitor?.onUtterance?.(new Blob(['s'], { type: 'audio/webm' }))
})
await waitFor(() => expect(onStopWord).toHaveBeenCalledTimes(1))
// Only the kickoff turn was submitted — the "stop" capture never was.
expect(onSubmit).toHaveBeenCalledTimes(1)
expect(onSubmit).not.toHaveBeenCalledWith('stop')
})
it('re-arms a single monitor per turn (idempotent ensure)', async () => {
const { hook } = renderConversation()
await act(async () => {
await hook.result.current.start()
})
await enterThinking(hook)
await waitFor(() => expect(monitorCalls.length).toBeGreaterThan(0))
const armed = monitorCalls.length
// Effect re-runs (busy toggles, status changes) must not open more mics.
hook.rerender({ busy: true })
hook.rerender({ busy: true })
expect(monitorCalls.length).toBe(armed)
})
})
@@ -0,0 +1,736 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useI18n } from '@/i18n'
import { startThinkingSound, stopThinkingSound } from '@/lib/thinking-sound'
import { monitorSpeechDuringPlayback } from '@/lib/voice-barge-in'
import {
markVoicePlaybackInterrupted,
playSpeechText,
type SpeechStreamSession,
startSpeechStream,
stopVoicePlayback
} from '@/lib/voice-playback'
import { isVoiceStopCommand } from '@/lib/voice-stop-word'
import { notify, notifyError } from '@/store/notifications'
import { $voicePlayback } from '@/store/voice-playback'
import { useMicRecorder } from './use-mic-recorder'
export type ConversationStatus = 'idle' | 'listening' | 'transcribing' | 'thinking' | 'speaking'
interface PendingVoiceResponse {
id: string
pending: boolean
text: string
}
interface VoiceConversationOptions {
busy: boolean
enabled: boolean
onFatalError?: () => void
/** Interrupt the in-flight agent turn (the same seam as the Stop button).
* Fired when the user speaks while the model is still generating. */
onInterrupt?: () => Promise<void> | void
onStopWord?: () => void
onSubmit: (text: string) => Promise<void> | void
onTranscribeAudio?: (audio: Blob) => Promise<string>
pendingResponse: () => PendingVoiceResponse | null
consumePendingResponse: () => void
/** Awaited right before the mic is opened. Used to let the wake-word listener
* fully release the capture device first, so the two never contend. */
beforeMicOpen?: () => Promise<void> | void
}
/** How long a barge-triggered interrupt may take to settle before we submit
* the captured utterance anyway. */
const INTERRUPT_SETTLE_TIMEOUT_MS = 5_000
export function useVoiceConversation({
busy,
enabled,
onFatalError,
onInterrupt,
onStopWord,
onSubmit,
onTranscribeAudio,
pendingResponse,
consumePendingResponse,
beforeMicOpen
}: VoiceConversationOptions) {
const { t } = useI18n()
const voiceCopy = t.notifications.voice
const { handle, level } = useMicRecorder(voiceCopy)
const [status, setStatus] = useState<ConversationStatus>('idle')
const [muted, setMuted] = useState(false)
const turnTimeoutRef = useRef<number | null>(null)
const pendingStartRef = useRef(false)
const turnClosingRef = useRef(false)
const awaitingSpokenResponseRef = useRef(false)
const responseIdRef = useRef<string | null>(null)
const spokenSourceLengthRef = useRef(0)
const speechSessionRef = useRef<null | SpeechStreamSession>(null)
const stopBargeMonitorRef = useRef<(() => void) | null>(null)
const bargeCapturePendingRef = useRef(false)
const bargedRef = useRef(false)
const speechStartSequenceRef = useRef(0)
const enabledRef = useRef(enabled)
const mutedRef = useRef(muted)
const busyRef = useRef(busy)
const statusRef = useRef<ConversationStatus>('idle')
const wasEnabledRef = useRef(enabled)
const onStopWordRef = useRef(onStopWord)
const onInterruptRef = useRef(onInterrupt)
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
useEffect(() => {
onInterruptRef.current = onInterrupt
}, [onInterrupt])
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
useEffect(() => {
onStopWordRef.current = onStopWord
}, [onStopWord])
const beforeMicOpenRef = useRef(beforeMicOpen)
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
useEffect(() => {
beforeMicOpenRef.current = beforeMicOpen
}, [beforeMicOpen])
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
useEffect(() => {
enabledRef.current = enabled
}, [enabled])
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
useEffect(() => {
mutedRef.current = muted
}, [muted])
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
useEffect(() => {
busyRef.current = busy
}, [busy])
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
useEffect(() => {
statusRef.current = status
}, [status])
const clearTurnTimeout = () => {
if (turnTimeoutRef.current) {
window.clearTimeout(turnTimeoutRef.current)
turnTimeoutRef.current = null
}
}
const dropSpeechSession = () => {
stopBargeMonitorRef.current?.()
stopBargeMonitorRef.current = null
bargeCapturePendingRef.current = false
bargedRef.current = false
speechSessionRef.current = null
responseIdRef.current = null
spokenSourceLengthRef.current = 0
}
const handleTurn = useCallback(
async (forceTranscribe = false) => {
if (turnClosingRef.current) {
return
}
turnClosingRef.current = true
clearTurnTimeout()
setStatus('transcribing')
try {
const result = await handle.stop()
if (!result || (!result.heardSpeech && !forceTranscribe) || !onTranscribeAudio) {
if (enabledRef.current && !mutedRef.current && !busyRef.current && statusRef.current !== 'speaking') {
pendingStartRef.current = true
}
setStatus('idle')
return
}
try {
const transcript = (await onTranscribeAudio(result.audio)).trim()
if (!transcript) {
if (enabledRef.current) {
pendingStartRef.current = true
}
setStatus('idle')
return
}
// A spoken "stop" (or "never mind", "goodbye", …) ends the
// conversation instead of being submitted as a turn. Only whole-
// utterance stop commands match, so "stop the container" still goes
// through as a real request.
if (isVoiceStopCommand(transcript)) {
dropSpeechSession()
setStatus('idle')
onStopWordRef.current?.()
return
}
awaitingSpokenResponseRef.current = true
dropSpeechSession()
await onSubmit(transcript)
setStatus('thinking')
} catch (error) {
notifyError(error, voiceCopy.transcriptionFailed)
if (enabledRef.current && !mutedRef.current && !busyRef.current) {
pendingStartRef.current = true
}
setStatus('idle')
}
} finally {
turnClosingRef.current = false
}
},
[handle, onSubmit, onTranscribeAudio, voiceCopy.transcriptionFailed]
)
const startListening = useCallback(async () => {
pendingStartRef.current = false
if (!enabledRef.current || mutedRef.current || busyRef.current) {
return
}
if (bargeCapturePendingRef.current) {
return // the barge monitor is mid-capture and owns the mic
}
if (statusRef.current !== 'idle') {
return
}
// Let the wake-word listener fully release the capture device before we
// open ours — opening the mic while wake still holds it makes getUserMedia
// fail (the "clicked voice but it never starts listening" bug).
try {
await beforeMicOpenRef.current?.()
} catch {
// A pause failure shouldn't block the user's explicit start.
}
// enabled/muted/busy or an interleaved turn may have changed while we waited.
if (!enabledRef.current || mutedRef.current || busyRef.current || statusRef.current !== 'idle') {
return
}
try {
// VAD tuning mirrors `tools.voice_mode` defaults so the browser loop matches the CLI.
await handle.start({
silenceLevel: 0.075,
silenceMs: 1_250,
idleSilenceMs: 12_000,
onError: error => {
notifyError(error, voiceCopy.microphoneFailed)
pendingStartRef.current = false
onFatalError?.()
},
onSilence: () => void handleTurn()
})
setStatus('listening')
// Clear any prior turn-timeout before arming a fresh one. Each listen
// cycle reassigns turnTimeoutRef; without clearing first, a stale 60s
// timer from an earlier cycle survives and later fires handleTurn() in
// the middle of a new listen, cutting it short (or, after enough idle
// re-listens, wedging the loop into a state it doesn't re-arm from).
clearTurnTimeout()
turnTimeoutRef.current = window.setTimeout(() => void handleTurn(), 60_000)
} catch (error) {
notifyError(error, voiceCopy.couldNotStartSession)
pendingStartRef.current = false
setStatus('idle')
onFatalError?.()
}
}, [handle, handleTurn, onFatalError, voiceCopy.couldNotStartSession, voiceCopy.microphoneFailed])
const settleAfterSpeech = useCallback(
(barged: boolean, stoppedDuringSetup = false) => {
if (barged || !awaitingSpokenResponseRef.current) {
awaitingSpokenResponseRef.current = false
consumePendingResponse()
}
if (bargeCapturePendingRef.current) {
// The barge monitor is still capturing the user's interruption — it
// owns the next turn. Keep it alive and don't re-open the mic; the
// utterance callback transcribes and submits when they go quiet.
speechSessionRef.current = null
responseIdRef.current = null
spokenSourceLengthRef.current = 0
setStatus('listening')
return
}
dropSpeechSession()
// If stopVoicePlayback() was called externally (Stop button, end), the
// voice-playback sequence has advanced past what we captured at speech
// start — don't auto-start the next sentence, the user chose to stop.
const stoppedByUser =
stoppedDuringSetup ||
(speechStartSequenceRef.current > 0 && $voicePlayback.get().sequence > speechStartSequenceRef.current)
speechStartSequenceRef.current = 0
if (enabledRef.current && !stoppedByUser) {
pendingStartRef.current = true
}
setStatus('idle')
},
[consumePendingResponse]
)
/**
* Submit the utterance the barge monitor captured — the user's interruption
* from its first syllable, no re-listen round trip. Empty/failed captures
* fall back to normal listening.
*/
const submitCapturedUtterance = useCallback(
async (audio: Blob | null) => {
const resumeListening = () => {
if (enabledRef.current && !mutedRef.current) {
pendingStartRef.current = true
}
setStatus('idle')
}
if (!audio || !onTranscribeAudio) {
resumeListening()
return
}
setStatus('transcribing')
try {
const transcript = (await onTranscribeAudio(audio)).trim()
if (!transcript) {
resumeListening()
return
}
// A spoken stop command while barging means "stop everything" — the
// turn/playback was already cut at trip time; now end the conversation
// instead of submitting "stop" as a new prompt.
if (isVoiceStopCommand(transcript)) {
dropSpeechSession()
setStatus('idle')
onStopWordRef.current?.()
return
}
// A generation-phase barge interrupted the in-flight turn; the submit
// path refuses while `busy`, so wait for the interrupt to settle.
const deadline = Date.now() + INTERRUPT_SETTLE_TIMEOUT_MS
while (busyRef.current && Date.now() < deadline) {
await new Promise(resolve => window.setTimeout(resolve, 100))
}
awaitingSpokenResponseRef.current = true
dropSpeechSession()
consumePendingResponse()
await onSubmit(transcript)
setStatus('thinking')
} catch (error) {
notifyError(error, voiceCopy.transcriptionFailed)
resumeListening()
}
},
[consumePendingResponse, onSubmit, onTranscribeAudio, voiceCopy.transcriptionFailed]
)
/**
* Full-duplex barge-in monitor for the WHOLE agent turn: armed at submit,
* live through generation (thinking) AND playback (speaking).
*
* - generation phase (`busy`): speech interrupts the in-flight turn via
* `onInterrupt` — the same seam as the Stop button — and cuts any TTS that
* managed to start, so the stale reply never speaks.
* - playback phase: speech cuts playback and the captured interruption is
* transcribed and submitted as the next turn.
*
* Idempotent — one monitor owns the mic per turn; re-arming while one is
* live is a no-op (the live/fallback speech paths and the turn-drive effect
* all call this).
*/
const ensureBargeMonitor = useCallback(() => {
if (stopBargeMonitorRef.current) {
return
}
stopBargeMonitorRef.current = monitorSpeechDuringPlayback({
isPlaying: () => $voicePlayback.get().status === 'speaking',
onSpeech: () => {
bargeCapturePendingRef.current = true
bargedRef.current = true
markVoicePlaybackInterrupted()
stopVoicePlayback()
if (busyRef.current) {
// Mid-generation: stop the in-flight turn so the captured utterance
// becomes the next one instead of queueing behind a stale reply.
void onInterruptRef.current?.()
}
},
onUtterance: audio => {
bargeCapturePendingRef.current = false
stopBargeMonitorRef.current = null
void submitCapturedUtterance(audio)
}
})
}, [submitCapturedUtterance])
/** Push any new reply text into the live session; finish when complete. */
const feedSpeechSession = useCallback(
(responseId: string) => {
const session = speechSessionRef.current
if (!session || responseIdRef.current !== responseId) {
return
}
const response = pendingResponse()
if (response && response.id === responseId) {
if (response.text.length > spokenSourceLengthRef.current) {
session.append(response.text.slice(spokenSourceLengthRef.current))
spokenSourceLengthRef.current = response.text.length
}
if (!response.pending && !busyRef.current) {
session.finish()
}
} else if (!busyRef.current) {
// Reply consumed/vanished while we were speaking — close out the turn.
session.finish()
}
},
[pendingResponse]
)
/** Whole-text fallback: wait for the reply to complete, then speak it. */
const awaitFallbackSpeech = useCallback(
(responseId: string) => {
const poll = () => {
if (responseIdRef.current !== responseId) {
return
}
const response = pendingResponse()
if (!response || response.id !== responseId) {
settleAfterSpeech(false)
return
}
if (response.pending || busyRef.current) {
window.setTimeout(poll, 250)
return
}
// The full-duplex monitor is normally already live (armed at submit);
// this is a safety net for read-aloud-style entries into the loop.
ensureBargeMonitor()
const playback = playSpeechText(response.text, { source: 'voice-conversation' })
// playSpeechText performs its normal cleanup synchronously before
// returning. Capture the sequence after that internal increment so
// only a later, external stop suppresses the next listen cycle.
speechStartSequenceRef.current = $voicePlayback.get().sequence
void playback
.catch(error => notifyError(error, voiceCopy.playbackFailed))
.finally(() => {
if (responseIdRef.current === responseId) {
awaitingSpokenResponseRef.current = false
settleAfterSpeech(bargedRef.current)
}
})
}
poll()
},
[ensureBargeMonitor, pendingResponse, settleAfterSpeech, voiceCopy.playbackFailed]
)
/**
* Live-speak the streaming reply: one speech session per response, fed
* incremental text as the assistant generates it. Audio overlaps generation
* — no wait for the full reply, no per-sentence gaps.
*/
const openLiveSpeech = useCallback(
(responseId: string) => {
const sequenceBeforeStart = $voicePlayback.get().sequence
responseIdRef.current = responseId
spokenSourceLengthRef.current = 0
setStatus('speaking')
// VAD barge-in: the user talking over the reply cuts playback, drops
// the not-yet-spoken remainder, AND keeps capturing — the interruption
// is transcribed from its first syllable instead of losing the opening
// words to a mic re-open. Usually already live (armed at submit).
ensureBargeMonitor()
void (async () => {
const session = await startSpeechStream({ source: 'voice-conversation' })
// The session may resolve after the loop moved on (barge, disable).
if (responseIdRef.current !== responseId) {
if (session) {
stopVoicePlayback()
}
return
}
if (!session) {
// Stream discovery can also fail after an explicit Stop landed
// during its async URL lookup. In that case, do not turn the stopped
// live attempt into fresh fallback playback.
if ($voicePlayback.get().sequence > sequenceBeforeStart) {
awaitingSpokenResponseRef.current = false
settleAfterSpeech(false, true)
return
}
// No streaming backend/provider: speak the whole reply once it lands.
speechSessionRef.current = null
awaitFallbackSpeech(responseId)
return
}
// startSpeechStream calls stopVoicePlayback once after its async URL
// lookup. A second sequence bump means the user pressed Stop while
// setup was still pending. Do not absorb that explicit stop into the
// post-start baseline or allow the new session to play.
const sequenceAfterStart = $voicePlayback.get().sequence
const stoppedDuringStart = sequenceAfterStart > sequenceBeforeStart + 1
speechStartSequenceRef.current = sequenceAfterStart
speechSessionRef.current = session
if (stoppedDuringStart) {
stopVoicePlayback()
awaitingSpokenResponseRef.current = false
settleAfterSpeech(false, true)
return
}
// Timer-driven feed: reply text flows into the session at delta rate
// regardless of React render cadence.
const feedTimer = window.setInterval(() => feedSpeechSession(responseId), 150)
feedSpeechSession(responseId)
const outcome = await session.done
window.clearInterval(feedTimer)
if (responseIdRef.current !== responseId) {
return
}
if (outcome === 'fallback') {
awaitFallbackSpeech(responseId)
return
}
awaitingSpokenResponseRef.current = false
settleAfterSpeech(bargedRef.current)
})()
},
[awaitFallbackSpeech, ensureBargeMonitor, feedSpeechSession, settleAfterSpeech]
)
const start = useCallback(async () => {
if (!onTranscribeAudio) {
notify({
kind: 'warning',
title: voiceCopy.unavailable,
message: voiceCopy.configureSpeechToText
})
onFatalError?.()
return
}
setMuted(false)
awaitingSpokenResponseRef.current = false
dropSpeechSession()
consumePendingResponse()
pendingStartRef.current = true
await startListening()
}, [
consumePendingResponse,
onFatalError,
onTranscribeAudio,
startListening,
voiceCopy.configureSpeechToText,
voiceCopy.unavailable
])
const end = useCallback(async () => {
pendingStartRef.current = false
clearTurnTimeout()
stopVoicePlayback()
handle.cancel()
turnClosingRef.current = false
awaitingSpokenResponseRef.current = false
dropSpeechSession()
consumePendingResponse()
setMuted(false)
setStatus('idle')
}, [consumePendingResponse, handle])
const stopTurn = useCallback(() => {
if (statusRef.current === 'listening') {
void handleTurn(true)
}
}, [handleTurn])
const toggleMute = useCallback(() => {
setMuted(value => {
const next = !value
if (next) {
clearTurnTimeout()
handle.cancel()
setStatus('idle')
} else if (enabledRef.current && !busyRef.current && statusRef.current === 'idle') {
pendingStartRef.current = true
}
return next
})
}, [handle])
useEffect(() => {
if (!enabled) {
return
}
const onKeyDown = (event: KeyboardEvent) => {
if (event.code !== 'Space' || event.repeat || event.metaKey || event.ctrlKey || event.altKey) {
return
}
if (statusRef.current !== 'listening') {
return
}
event.preventDefault()
stopTurn()
}
window.addEventListener('keydown', onKeyDown, { capture: true })
return () => window.removeEventListener('keydown', onKeyDown, { capture: true })
}, [enabled, stopTurn])
// Ambient "thinking" sound: while the agent works (status 'thinking') no
// audio flows, which reads as dead air mid-conversation. Calm bubble blips
// fill the gap; they stop the INSTANT speech starts, the mic re-arms, or the
// conversation ends. Gated by voice.thinking_sound + the shared sound mute.
useEffect(() => {
if (enabled && !muted && status === 'thinking') {
startThinkingSound()
return stopThinkingSound
}
stopThinkingSound()
return undefined
}, [enabled, muted, status])
// Drive the loop: when a voice-submitted reply appears, open a live speech
// session (which feeds itself from then on). Otherwise start listening when
// idle between turns.
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
useEffect(() => {
if (!enabled || muted) {
return
}
if (awaitingSpokenResponseRef.current && status !== 'speaking') {
// Generation phase: the turn is in flight but no reply audio exists
// yet. Keep the mic live so speech can interrupt the model mid-
// generation (full-duplex) instead of going deaf until playback.
if (status === 'thinking' && (busy || bargeCapturePendingRef.current)) {
ensureBargeMonitor()
}
const response = pendingResponse()
if (response) {
openLiveSpeech(response.id)
return
}
if (!busy && status === 'thinking' && !bargeCapturePendingRef.current) {
// Turn finished without any speakable reply (tool-only, error). A
// live barge capture owns the loop instead — it submits or resumes.
awaitingSpokenResponseRef.current = false
dropSpeechSession()
pendingStartRef.current = true
setStatus('idle')
return
}
}
if (busy || status !== 'idle') {
return
}
if (pendingStartRef.current) {
void startListening()
}
}, [busy, enabled, muted, ensureBargeMonitor, openLiveSpeech, pendingResponse, startListening, status])
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
useEffect(() => {
if (enabled && !wasEnabledRef.current) {
void start()
}
if (!enabled && wasEnabledRef.current) {
void end()
}
wasEnabledRef.current = enabled
}, [enabled, end, start])
return { end, level, muted, start, status, stopTurn, toggleMute }
}
@@ -0,0 +1,116 @@
import { useEffect, useRef, useState } from 'react'
import { useI18n } from '@/i18n'
import { notify, notifyError } from '@/store/notifications'
import type { VoiceActivityState, VoiceStatus } from '../types'
import { useMicRecorder } from './use-mic-recorder'
interface VoiceRecorderOptions {
maxRecordingSeconds: number
onTranscribeAudio?: (audio: Blob) => Promise<string>
focusInput: () => void
onTranscript: (text: string) => void
}
export function useVoiceRecorder({
maxRecordingSeconds,
onTranscribeAudio,
focusInput,
onTranscript
}: VoiceRecorderOptions) {
const { t } = useI18n()
const voiceCopy = t.notifications.voice
const { handle, level, recording } = useMicRecorder(voiceCopy)
const [voiceStatus, setVoiceStatus] = useState<VoiceStatus>('idle')
const [elapsedSeconds, setElapsedSeconds] = useState(0)
const startedAtRef = useRef(0)
const intervalRef = useRef<number | null>(null)
const timeoutRef = useRef<number | null>(null)
const clearTimers = () => {
if (intervalRef.current) {
window.clearInterval(intervalRef.current)
intervalRef.current = null
}
if (timeoutRef.current) {
window.clearTimeout(timeoutRef.current)
timeoutRef.current = null
}
}
useEffect(() => () => clearTimers(), [])
const stop = async () => {
clearTimers()
const result = await handle.stop()
if (!result) {
setVoiceStatus('idle')
return
}
if (!onTranscribeAudio) {
setVoiceStatus('idle')
return
}
setVoiceStatus('transcribing')
try {
const transcript = (await onTranscribeAudio(result.audio)).trim()
if (!transcript) {
notify({ kind: 'warning', title: voiceCopy.noSpeechDetected, message: voiceCopy.tryRecordingAgain })
} else {
onTranscript(transcript)
}
} catch (error) {
notifyError(error, voiceCopy.transcriptionFailed)
} finally {
setVoiceStatus('idle')
focusInput()
}
}
const start = async () => {
if (!onTranscribeAudio) {
notify({ kind: 'warning', title: voiceCopy.unavailable, message: voiceCopy.transcriptionUnavailable })
return
}
try {
await handle.start({ onError: error => notifyError(error, voiceCopy.recordingFailed) })
startedAtRef.current = Date.now()
setElapsedSeconds(0)
setVoiceStatus('recording')
intervalRef.current = window.setInterval(() => setElapsedSeconds((Date.now() - startedAtRef.current) / 1000), 250)
const cap = Math.max(1, Math.min(Math.trunc(maxRecordingSeconds), 600))
timeoutRef.current = window.setTimeout(() => void stop(), cap * 1000)
} catch (error) {
setVoiceStatus('idle')
notifyError(error, voiceCopy.recordingFailed)
}
}
const dictate = () => {
if (recording) {
void stop()
} else if (voiceStatus === 'idle') {
void start()
}
}
const voiceActivityState: VoiceActivityState = {
elapsedSeconds,
level,
status: voiceStatus
}
return { dictate, voiceActivityState, voiceStatus }
}
@@ -0,0 +1,197 @@
import { act, cleanup, fireEvent, render } from '@testing-library/react'
import { useRef, useState } from 'react'
import { afterEach, describe, expect, it } from 'vitest'
// No global setupFiles registers auto-cleanup, so unmount between tests —
// otherwise a second render() leaks the first editor and getByTestId('editor')
// matches multiple nodes.
afterEach(cleanup)
// Faithful mirror of index.tsx's composer text wiring for IME input, driven
// through REAL DOM composition + input events on a contentEditable.
//
// Regression repro for #39614: typing committed multi-character IME text (e.g.
// Chinese "你好") used to leave the send button hidden. The input events fired
// during composition carry uncommitted preedit text and are intentionally
// skipped; Chromium then does NOT reliably emit a trailing input event after
// compositionend on Windows IMEs, so the finalized text never reached composer
// state and `hasPayload` stayed false until an unrelated edit forced a sync.
// The fix flushes the live DOM text in onCompositionEnd.
function Harness({ onPayload }: { onPayload: (hasPayload: boolean) => void }) {
const editorRef = useRef<HTMLDivElement>(null)
const composingRef = useRef(false)
const draftRef = useRef('')
const [draft, setDraft] = useState('')
const flushEditorToDraft = (editor: HTMLDivElement) => {
const next = editor.textContent ?? ''
if (next !== draftRef.current) {
draftRef.current = next
setDraft(next)
}
}
onPayload(draft.trim().length > 0)
return (
<div
contentEditable
data-testid="editor"
onCompositionEnd={event => {
composingRef.current = false
flushEditorToDraft(event.currentTarget)
}}
onCompositionStart={() => {
composingRef.current = true
}}
onInput={event => {
if (composingRef.current) {
return
}
flushEditorToDraft(event.currentTarget)
}}
ref={editorRef}
suppressContentEditableWarning
/>
)
}
describe('composer IME composition — send button visibility (#39614)', () => {
it('shows the send button after committing CJK text without a trailing edit', async () => {
let hasPayload = false
const { getByTestId } = render(<Harness onPayload={p => (hasPayload = p)} />)
const editor = getByTestId('editor')
// Compose "你好" the way a Windows Chinese IME does: compositionstart, then
// input events carrying uncommitted preedit text, then compositionend with
// the committed text already in the DOM — and crucially NO input event
// afterwards.
await act(async () => {
fireEvent.compositionStart(editor)
editor.textContent = '你'
fireEvent.input(editor)
editor.textContent = '你好'
fireEvent.input(editor)
fireEvent.compositionEnd(editor)
})
// Before the fix this was false (button hidden) until a further edit.
expect(hasPayload).toBe(true)
expect(editor.textContent).toBe('你好')
})
it('also covers Japanese/Korean and any IME-composed script', async () => {
let hasPayload = false
const { getByTestId } = render(<Harness onPayload={p => (hasPayload = p)} />)
const editor = getByTestId('editor')
for (const committed of ['こんにちは', '안녕하세요']) {
await act(async () => {
fireEvent.compositionStart(editor)
editor.textContent = committed
fireEvent.input(editor)
fireEvent.compositionEnd(editor)
})
expect(hasPayload).toBe(true)
// Clear for the next script.
await act(async () => {
editor.textContent = ''
fireEvent.input(editor)
})
expect(hasPayload).toBe(false)
}
})
it('blocks Enter with keyCode 229 even after compositionend (macOS Chinese IME)', async () => {
let submitCount = 0
let hasPayload = false
function KeyDownHarness({ onPayload }: { onPayload: (hasPayload: boolean) => void }) {
const editorRef = useRef<HTMLDivElement>(null)
const composingRef = useRef(false)
const draftRef = useRef('')
const [draft, setDraft] = useState('')
const flushEditorToDraft = (editor: HTMLDivElement) => {
const next = editor.textContent ?? ''
if (next !== draftRef.current) {
draftRef.current = next
setDraft(next)
}
}
onPayload(draft.trim().length > 0)
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (composingRef.current || event.nativeEvent.isComposing) {
return
}
if (event.key === 'Enter' && event.keyCode === 229) {
return
}
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault()
submitCount++
}
}
return (
<div
contentEditable
data-testid="editor"
onCompositionEnd={event => {
composingRef.current = false
flushEditorToDraft(event.currentTarget)
}}
onCompositionStart={() => {
composingRef.current = true
}}
onInput={event => {
if (composingRef.current) {
return
}
flushEditorToDraft(event.currentTarget)
}}
onKeyDown={handleKeyDown}
ref={editorRef}
suppressContentEditableWarning
/>
)
}
const { getByTestId } = render(<KeyDownHarness onPayload={p => (hasPayload = p)} />)
const editor = getByTestId('editor')
// Simulate macOS Chinese IME: compositionend fires, then Enter with keyCode 229.
await act(async () => {
fireEvent.compositionStart(editor)
editor.textContent = '测试'
fireEvent.input(editor)
fireEvent.compositionEnd(editor)
})
expect(hasPayload).toBe(true)
// This Enter must NOT trigger submit.
await act(async () => {
fireEvent.keyDown(editor, { key: 'Enter', keyCode: 229 })
})
expect(submitCount).toBe(0)
// A normal Enter afterwards should still submit.
await act(async () => {
fireEvent.keyDown(editor, { key: 'Enter', keyCode: 13 })
})
expect(submitCount).toBe(1)
})
})
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,72 @@
import { describe, expect, it } from 'vitest'
import { refAttrs, refAttrsHtml } from '@/components/assistant-ui/directive-text'
import { REFERENCE_STYLES, referenceKind, referenceStyle } from '@/components/assistant-ui/reference-kinds'
/**
* There is ONE inline-reference system: `class="ref"` + `data-ref="<kind>"`.
* A pasted link, an `@file:` chip, a `/skill`, a `@session:` the agent wrote —
* all the same markup, styled by the `.ref` rules in styles.css.
*/
describe('the inline reference contract', () => {
it('marks any element as a reference of a given kind', () => {
expect(refAttrs('file')).toEqual({ className: 'ref', 'data-ref': 'file' })
expect(refAttrsHtml('skill')).toBe('class="ref" data-ref="skill"')
})
it('an unkinded reference is a plain link, not a broken one', () => {
// A bare external link has no kind — it keeps the default link colour
// rather than being tagged with a wrong one.
expect(refAttrs()).toEqual({ className: 'ref' })
expect(refAttrsHtml()).toBe('class="ref"')
})
it('normalises an unknown kind instead of emitting it raw', () => {
// A kind CSS has no rule for would silently render unstyled; coercing to
// `other` keeps it inside the system.
expect(refAttrs('wat')['data-ref']).toBe('other')
expect(referenceKind('wat')).toBe('other')
})
it('ships no colour from TypeScript — the theme owns every accent', () => {
// The whole point of keying on `data-ref`: a skin restyles all references
// at once, and no hex or color-mix() is hardcoded in a component.
for (const [kind, style] of Object.entries(REFERENCE_STYLES)) {
expect(style, `${kind} must not carry a colour`).not.toHaveProperty('color')
}
expect(JSON.stringify(refAttrs('url'))).not.toMatch(/color|#[0-9a-f]{3}/i)
})
it('gives every kind a glyph and a label', () => {
for (const [kind, style] of Object.entries(REFERENCE_STYLES)) {
expect(style.codicon, `${kind} codicon`).toBeTruthy()
expect(style.label, `${kind} label`).toBeTruthy()
// Emoji rows render the emoji itself instead of a glyph.
if (kind !== 'emoji') {
expect(style.paths.length, `${kind} paths`).toBeGreaterThan(0)
}
}
})
it('keeps commands and skills visually distinct', () => {
// Different data-ref values, so the stylesheet can accent them apart.
expect(refAttrs('skill')['data-ref']).not.toBe(refAttrs('command')['data-ref'])
expect(referenceStyle('skill').codicon).not.toBe(referenceStyle('command').codicon)
})
})
describe('references are text, not badges', () => {
it('carries no layout, padding, or background of its own', () => {
// Everything visual lives in the stylesheet. If a component starts adding
// its own chrome here, that's the drift this system exists to prevent.
const { className } = refAttrs('file')
expect(className).toBe('ref')
for (const chrome of ['bg-', 'rounded', 'px-', 'py-', 'border', 'inline-flex', 'text-[']) {
expect(className).not.toContain(chrome)
}
})
})
@@ -0,0 +1,186 @@
import { formatRefValue } from '@/components/assistant-ui/directive-text'
import { translateNow } from '@/i18n'
import { contextPath } from '@/lib/chat-runtime'
import type { DroppedFile } from '../hooks/use-composer-actions'
import {
composerPlainText,
normalizeComposerEditorDom,
placeCaretEnd,
refChipElement,
RICH_INPUT_SLOT
} from './rich-editor'
/** A chip to insert: a raw `@kind:value` string, or a typed value + display label. */
export type InlineRefInput = string | { kind: string; label?: string; value: string }
/** A dragged sidebar session — carried in-memory by the pointer drag session
* (session-drag.ts); sessions never ride native DnD. */
export interface SessionDragPayload {
id: string
profile: string
title: string
}
/** A session's friendly display label — its title, or a localized fallback. */
export const sessionLabel = ({ id, title }: SessionDragPayload) =>
title || translateNow('sidebar.row.untitledChat', id.slice(0, 8))
/** Build a `@session:<profile>/<id>` chip. Value carries the metadata the agent
* needs to resolve the link (session_search); label shows the friendly title. */
export function sessionInlineRef(payload: SessionDragPayload): InlineRefInput {
return { kind: 'session', label: sessionLabel(payload), value: `${payload.profile || 'default'}/${payload.id}` }
}
export function dragHasAttachments(transfer: DataTransfer | null, pathsMime: string) {
if (!transfer) {
return false
}
if (Array.from(transfer.types || []).includes(pathsMime)) {
return true
}
if (Array.from(transfer.types || []).includes('Files')) {
return true
}
return Array.from(transfer.items || []).some(item => item.kind === 'file')
}
export function droppedFileInlineRef(candidate: DroppedFile, cwd: string | null | undefined) {
if (!candidate.path) {
return null
}
const rel = contextPath(candidate.path, cwd || '')
if (candidate.line) {
const { line, lineEnd } = candidate
const range = lineEnd && lineEnd > line ? `${line}-${lineEnd}` : `${line}`
return `@line:${formatRefValue(`${rel}:${range}`)}`
}
const kind = candidate.isDirectory ? 'folder' : 'file'
return `@${kind}:${formatRefValue(rel)}`
}
/** Resolve a batch of drops to their inline `@file:`/`@line:`/`@folder:` refs,
* dropping any that carry no path. */
export function droppedFileInlineRefs(candidates: DroppedFile[], cwd: string | null | undefined): string[] {
return candidates.map(candidate => droppedFileInlineRef(candidate, cwd)).filter((ref): ref is string => Boolean(ref))
}
function parseInlineRef(ref: InlineRefInput): { kind: string; label?: string; rawValue: string } | null {
if (typeof ref !== 'string') {
return { kind: ref.kind, label: ref.label, rawValue: ref.value }
}
const match = ref.match(/^@([^:]+):(.+)$/)
if (!match) {
return null
}
return { kind: match[1] || 'file', rawValue: match[2] || '' }
}
function plainTextInRange(editor: HTMLDivElement, range: Range, edge: 'after' | 'before') {
const slice = range.cloneRange()
slice.selectNodeContents(editor)
if (edge === 'before') {
slice.setEnd(range.startContainer, range.startOffset)
} else {
slice.setStart(range.endContainer, range.endOffset)
}
// Carry the editor's slot marker: composerPlainText appends a trailing "\n"
// to any other block element, so a bare <div> made `beforeText` always look
// like it ended in whitespace and the separating space was never inserted —
// a chip dropped after a word came out glued to it (`review@file:...`).
const container = document.createElement('div')
container.dataset.slot = RICH_INPUT_SLOT
container.appendChild(slice.cloneContents())
return composerPlainText(container)
}
function buildRefFragment(
refs: readonly { kind: string; label?: string; rawValue: string }[],
{ needsBeforeSpace, needsAfterSpace }: { needsAfterSpace: boolean; needsBeforeSpace: boolean }
) {
const fragment = document.createDocumentFragment()
if (needsBeforeSpace) {
fragment.append(document.createTextNode(' '))
}
refs.forEach((ref, index) => {
if (index > 0) {
fragment.append(document.createTextNode(' '))
}
fragment.append(refChipElement(ref.kind, ref.rawValue, ref.label))
})
if (needsAfterSpace) {
fragment.append(document.createTextNode(' '))
}
return fragment
}
export function insertInlineRefsIntoEditor(editor: HTMLDivElement, refs: readonly InlineRefInput[]) {
const parsed = refs.map(parseInlineRef).filter((ref): ref is NonNullable<typeof ref> => ref !== null)
const hasEmptySentinel = editor.childNodes.length === 1 && editor.firstChild?.nodeName === 'BR'
if (!parsed.length) {
return null
}
if (hasEmptySentinel) {
editor.replaceChildren()
}
editor.focus({ preventScroll: true })
const selection = window.getSelection()
const range =
!hasEmptySentinel && selection?.rangeCount && editor.contains(selection.getRangeAt(0).commonAncestorContainer)
? selection.getRangeAt(0)
: null
if (range && selection) {
const beforeText = plainTextInRange(editor, range, 'before')
const afterText = plainTextInRange(editor, range, 'after')
range.insertNode(
buildRefFragment(parsed, {
needsAfterSpace: afterText.length === 0 || !/^\s/.test(afterText),
needsBeforeSpace: beforeText.length > 0 && !/\s$/.test(beforeText)
})
)
range.collapse(false)
selection.removeAllRanges()
selection.addRange(range)
} else {
const current = composerPlainText(editor)
editor.append(
buildRefFragment(parsed, {
needsAfterSpace: true,
needsBeforeSpace: current.length > 0 && !/\s$/.test(current)
})
)
placeCaretEnd(editor)
}
normalizeComposerEditorDom(editor)
return composerPlainText(editor)
}
@@ -0,0 +1,71 @@
import { memo, useState } from 'react'
import { composerFloatingPill } from '@/components/chat/composer-dock'
import { Codicon } from '@/components/ui/codicon'
import { useSessionSlice } from '@/lib/use-session-slice'
import { cn } from '@/lib/utils'
import { $composerActionsBySession, type ComposerAction } from '@/store/composer-actions'
import { notifyError } from '@/store/notifications'
/**
* Floating pill — the shared treatment for a control that sits over the
* composer (`composerFloatingPill`), plus this strip's own width cap and
* disabled state.
*
* NEVER `pointer-events-none`, not even when disabled. The pop-out drag region
* is an `absolute` sibling behind these pills, so a pill that stops taking
* pointer events hands the hit test straight to it — and a dead-looking badge
* becomes a grab handle that floats the composer.
*/
const PILL = cn(
composerFloatingPill,
'max-w-56',
'disabled:cursor-default disabled:opacity-50 disabled:hover:bg-(--composer-fill)',
'focus-visible:bg-(--chrome-action-hover) focus-visible:text-foreground focus-visible:outline-none focus-visible:ring-[0.1875rem] focus-visible:ring-ring/50'
)
/**
* The micro-action pills. Layout-free on purpose — the composer owns the strip
* (`composerFloatingStrip`), this owns only the pills, so the strip above the
* surface and the `composer.underside` strip below it can't drift apart.
*/
export const ActionBadges = memo(function ActionBadges({ sessionId }: { sessionId: null | string }) {
const actions = useSessionSlice($composerActionsBySession, sessionId)
// A pill can kick off async work (a gateway call, a submit). Track which one
// is in flight so it can spin and lock instead of double-firing.
const [runningId, setRunningId] = useState<null | string>(null)
const run = async (action: ComposerAction) => {
if (runningId || !sessionId) {
return
}
setRunningId(action.id)
try {
await action.run(sessionId)
} catch (error) {
notifyError(error, action.label)
} finally {
setRunningId(null)
}
}
return actions.map(action => {
const running = runningId === action.id
const glyph = running ? 'loading' : action.icon
return (
<button
className={PILL}
disabled={action.disabled || Boolean(runningId)}
key={action.id}
onClick={() => void run(action)}
type="button"
>
{glyph && <Codicon className="shrink-0 opacity-70" name={glyph} size="0.75rem" spinning={running} />}
<span className="truncate">{action.label}</span>
</button>
)
})
})
@@ -0,0 +1,117 @@
import { cleanup, render, screen } from '@testing-library/react'
import { atom } from 'nanostores'
import { afterEach, describe, expect, it } from 'vitest'
import type { ChatBarState } from '@/app/chat/composer/types'
import { type SessionView, SessionViewProvider } from '@/app/chat/session-view'
import { $activeSessionId, $currentModel, setCurrentModel, setCurrentModelSource } from '@/store/session'
import { ModelPill } from './model-pill'
const modelState = (over: Partial<ChatBarState['model']> = {}): ChatBarState['model'] => ({
canSwitch: true,
model: 'gpt-6',
provider: 'openai',
...over
})
afterEach(() => {
cleanup()
$activeSessionId.set(null)
setCurrentModel('')
setCurrentModelSource('')
})
// #62055: a manual composer pick is sticky and silently overrides the
// Settings → Model default for every NEW chat. The pill must say so.
describe('ModelPill pinned-override badge', () => {
it('shows the pin dot on a draft running a manual pick', () => {
setCurrentModel('deepseek/deepseek-v4-flash')
setCurrentModelSource('manual')
$activeSessionId.set(null)
render(<ModelPill disabled={false} model={modelState({ model: 'deepseek/deepseek-v4-flash' })} />)
expect(screen.getByTestId('model-pinned-dot')).toBeTruthy()
})
it('stays quiet when the composer reflects the profile default', () => {
setCurrentModel('google/gemma-4-26b-a4b-it:free')
setCurrentModelSource('default')
$activeSessionId.set(null)
render(<ModelPill disabled={false} model={modelState()} />)
expect(screen.queryByTestId('model-pinned-dot')).toBeNull()
})
it('stays quiet on a live session (footer shows that session, not the pin)', () => {
setCurrentModel('deepseek/deepseek-v4-flash')
setCurrentModelSource('manual')
$activeSessionId.set('live-1')
render(<ModelPill disabled={false} model={modelState()} />)
expect(screen.queryByTestId('model-pinned-dot')).toBeNull()
})
it('is exercised in both render paths', () => {
setCurrentModel('deepseek/deepseek-v4-flash')
setCurrentModelSource('manual')
$activeSessionId.set(null)
// Fallback (no live menu) path.
const { unmount } = render(
<ModelPill disabled={false} model={modelState({ model: 'deepseek/deepseek-v4-flash' })} />
)
expect(screen.getByTestId('model-pinned-dot')).toBeTruthy()
unmount()
// Live-menu (dropdown) path.
render(
<ModelPill
disabled={false}
model={modelState({ model: 'deepseek/deepseek-v4-flash', modelMenuContent: <div /> })}
/>
)
expect(screen.getByTestId('model-pinned-dot')).toBeTruthy()
expect($currentModel.get()).toBe('deepseek/deepseek-v4-flash')
})
})
describe('ModelPill per-surface model label', () => {
it('shows the chat-bar model even when the primary global differs', () => {
setCurrentModel('primary/model')
$activeSessionId.set('primary-runtime')
const tileView: SessionView = {
kind: 'tile',
$awaitingResponse: atom(false),
$busy: atom(false),
$cwd: atom(''),
$fast: atom(false),
$lastVisibleIsUser: atom(false),
$messages: atom([]),
$messagesEmpty: atom(true),
$model: atom('tile/claude-sonnet'),
$provider: atom('anthropic'),
$reasoningEffort: atom('high'),
$runtimeId: atom('tile-runtime'),
$storedId: atom('stored-tile'),
$turnStartedAt: atom<number | null>(null)
}
render(
<SessionViewProvider value={tileView}>
<ModelPill
disabled={false}
model={modelState({ model: 'tile/claude-sonnet', provider: 'anthropic', modelMenuContent: <div /> })}
/>
</SessionViewProvider>
)
expect(screen.getByText('Sonnet · High')).toBeTruthy()
expect(screen.queryByText(/primary/i)).toBeNull()
})
})
@@ -0,0 +1,188 @@
import { useStore } from '@nanostores/react'
import { useEffect, useState } from 'react'
import { useSessionView } from '@/app/chat/session-view'
import { useTourMarker } from '@/app/chat/tour-marker'
import { ModelMenuCloseContext } from '@/app/shell/model-menu-panel'
import { Button } from '@/components/ui/button'
import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
import { GlyphSpinner } from '@/components/ui/glyph-spinner'
import { releaseTypingFocus } from '@/components/ui/keyboard-first'
import { Tip } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
import { ChevronDown } from '@/lib/icons'
import { formatModelStatusLabel } from '@/lib/model-status-label'
import { cn } from '@/lib/utils'
import { $currentModelSource, $defaultReasoningEffort, setModelPickerOpen } from '@/store/session'
import { onComposerModelMenuRequest } from './focus'
import { useComposerScope } from './scope'
import type { ChatBarState } from './types'
// `shrink` (not `shrink-0`) with a truncating label: the pill is the one
// control in the row that can give width back continuously, so it absorbs the
// squeeze between collapse stages instead of pushing Send past the edge.
const PILL = cn(
'h-(--composer-control-size) min-w-0 max-w-40 shrink gap-1 rounded-md px-2 text-xs font-normal',
'text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground'
)
/**
* Composer model selector — the relocated status-bar pill. Reuses the live
* `model.options` dropdown (`modelMenuContent`) verbatim; falls back to the
* full picker when the gateway is closed and no live menu exists.
*
* Display follows THIS surface's SessionView (primary or tile) — never the
* primary-only globals — so side-by-side panes each show their own model.
*/
export function ModelPill({
compact = false,
disabled,
model
}: {
compact?: boolean
disabled: boolean
model: ChatBarState['model']
}) {
const copy = useI18n().t.shell.statusbar
// Two return branches below, one handle: only ever one of them mounts.
const tourMarker = useTourMarker('model-pill')
const view = useSessionView()
// Prefer the chat-bar snapshot (already view-scoped by ChatView); fall back
// to the live SessionView atoms so a mid-flight session.info still paints.
const viewModel = useStore(view.$model)
const viewProvider = useStore(view.$provider)
const currentModel = model.model || viewModel
const currentProvider = model.provider || viewProvider
const fastMode = useStore(view.$fast)
const reasoningEffort = useStore(view.$reasoningEffort)
const modelSource = useStore($currentModelSource)
const defaultEffort = useStore($defaultReasoningEffort)
const runtimeId = useStore(view.$runtimeId)
const [open, setOpen] = useState(false)
const scope = useComposerScope()
const hasLiveMenu = Boolean(model.modelMenuContent)
// The `composer.modelPicker` hotkey, routed to exactly one surface (the pane
// under the pointer, else the active composer — see requestModelMenuToggle).
// Toggles the live dropdown; with no live menu (gateway closed) it opens the
// full picker dialog, same as clicking the pill.
useEffect(
() =>
onComposerModelMenuRequest(target => {
if (target !== scope.target || disabled) {
return
}
if (hasLiveMenu) {
setOpen(prev => !prev)
} else {
setModelPickerOpen(true)
}
}),
[scope.target, disabled, hasLiveMenu]
)
// The composer pick is sticky: a manual selection is pinned and every NEW
// chat uses it instead of the Settings → Model default — silently, which has
// cost users real money on a forgotten paid-model pick (#62055). Surface the
// pin whenever a draft (no live session) is running on a manual override. A
// live session's footer reflects that session's model, so no badge there.
// Tiles always have a runtime — pin badge is primary-draft only.
const pinnedOverride =
view.kind === 'primary' && !runtimeId && modelSource === 'manual' && Boolean(currentModel.trim())
// The model resolves a beat after the gateway/session comes up. Rather than
// flash a literal "No model", show a quiet loader (inherits the pill text
// color at half opacity) until a model lands.
const label = compact ? (
<ChevronDown className="size-3.5 shrink-0 opacity-70" />
) : (
<>
{currentModel.trim() ? (
<span className="truncate">
{formatModelStatusLabel(currentModel, { defaultEffort, fastMode, reasoningEffort })}
</span>
) : (
<GlyphSpinner className="opacity-50" spinner="braille" />
)}
{pinnedOverride && (
<span
aria-label={copy.modelPinned}
className="size-1 shrink-0 rounded-full bg-(--ui-accent)"
data-testid="model-pinned-dot"
role="img"
/>
)}
<ChevronDown className="size-2.5 shrink-0 opacity-50" />
</>
)
// Compact (floating composer): a snug square holding just the chevron — no pill
// padding, sized to match the other composer icon buttons.
const pillClass = compact
? cn(
'size-(--composer-control-size) shrink-0 justify-center gap-0 rounded-md p-0',
'text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground'
)
: PILL
const baseTitle = currentProvider
? copy.modelTitle(currentProvider, currentModel || copy.modelNone)
: copy.switchModel
const title = pinnedOverride ? `${baseTitle} — ${copy.modelPinned}` : baseTitle
if (!model.modelMenuContent) {
return (
<Tip label={pinnedOverride ? `${copy.openModelPicker} — ${copy.modelPinned}` : copy.openModelPicker} side="top">
<Button
aria-label={copy.openModelPicker}
className={pillClass}
data-tour={tourMarker}
disabled={disabled}
onClick={() => setModelPickerOpen(true)}
type="button"
variant="ghost"
>
{label}
</Button>
</Tip>
)
}
// Closing the menu ends its claim on the keyboard: Radix restores focus to
// this pill (a toolbar button), so without the release the Enter that
// committed a model also swallows whatever you type next.
const setMenuOpen = (next: boolean) => {
setOpen(next)
if (!next) {
releaseTypingFocus()
}
}
return (
<DropdownMenu onOpenChange={setMenuOpen} open={open}>
<Tip label={title} side="top">
<DropdownMenuTrigger asChild>
<Button
aria-label={title}
className={pillClass}
data-tour={tourMarker}
disabled={disabled}
type="button"
variant="ghost"
>
{label}
</Button>
</DropdownMenuTrigger>
</Tip>
<DropdownMenuContent align="end" className="w-64 p-0" side="top" sideOffset={8}>
<ModelMenuCloseContext.Provider value={() => setMenuOpen(false)}>
{model.modelMenuContent}
</ModelMenuCloseContext.Provider>
</DropdownMenuContent>
</DropdownMenu>
)
}
@@ -0,0 +1,135 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { onComposerAttachImagesRequest, onComposerFocusRequest, onComposerInsertRequest } from './focus'
import { handleWindowPaste, routeClipboardToComposer } from './paste-to-focus'
/** Minimal DataTransfer stand-in: text/plain + optional image file items. */
function clipboard({ files = [] as File[], text = '' } = {}): DataTransfer {
return {
files: { item: (i: number) => files[i] ?? null, length: files.length },
getData: (type: string) => (type === 'text' || type === 'text/plain' ? text : ''),
items: files.map(file => ({ getAsFile: () => file, kind: 'file', type: file.type }))
} as unknown as DataTransfer
}
const image = (name = 'shot.png') => new File([new Uint8Array(8192)], name, { type: 'image/png' })
/** The bus defers dispatch a macrotask; flush it. */
const flushBus = () => new Promise(resolve => setTimeout(resolve, 1))
function pasteEvent(clip: DataTransfer, target: EventTarget = document.body): ClipboardEvent {
const event = new Event('paste', { bubbles: true, cancelable: true }) as ClipboardEvent
Object.defineProperty(event, 'clipboardData', { value: clip })
Object.defineProperty(event, 'target', { value: target })
return event
}
afterEach(() => {
document.body.replaceChildren()
vi.restoreAllMocks()
})
describe('routeClipboardToComposer', () => {
it('routes pasted text to the composer as an insert', async () => {
const inserts: string[] = []
const off = onComposerInsertRequest(({ text }) => inserts.push(text))
expect(routeClipboardToComposer(clipboard({ text: 'hello there' }))).toBe(true)
await flushBus()
off()
expect(inserts).toEqual(['hello there'])
})
it('chips a pasted link the same way a focused paste does', async () => {
const inserts: string[] = []
const off = onComposerInsertRequest(({ text }) => inserts.push(text))
routeClipboardToComposer(clipboard({ text: 'see https://example.com/docs' }))
await flushBus()
off()
expect(inserts[0]).toContain('@url:')
})
it('attaches clipboard images and pulls focus on an image-only paste', async () => {
const attached: Blob[][] = []
const focused: boolean[] = []
const offAttach = onComposerAttachImagesRequest(({ blobs }) => attached.push(blobs))
const offFocus = onComposerFocusRequest(() => focused.push(true))
expect(routeClipboardToComposer(clipboard({ files: [image()] }))).toBe(true)
await flushBus()
offAttach()
offFocus()
expect(attached).toHaveLength(1)
expect(attached[0]).toHaveLength(1)
expect(focused).toHaveLength(1)
})
it('takes both from a mixed paste — images attach AND the text inserts', async () => {
const attached: Blob[][] = []
const inserts: string[] = []
const offAttach = onComposerAttachImagesRequest(({ blobs }) => attached.push(blobs))
const offInsert = onComposerInsertRequest(({ text }) => inserts.push(text))
routeClipboardToComposer(clipboard({ files: [image()], text: 'look at this' }))
await flushBus()
offAttach()
offInsert()
expect(attached).toHaveLength(1)
expect(inserts).toEqual(['look at this'])
})
it('reports an empty clipboard as unhandled', () => {
expect(routeClipboardToComposer(clipboard())).toBe(false)
})
})
describe('handleWindowPaste', () => {
it('swallows a routed paste on non-editable chrome', () => {
const event = pasteEvent(clipboard({ text: 'hi' }))
handleWindowPaste(event)
expect(event.defaultPrevented).toBe(true)
})
it('yields to editable targets — they own their own paste', () => {
const input = document.createElement('input')
document.body.append(input)
const event = pasteEvent(clipboard({ text: 'hi' }), input)
handleWindowPaste(event)
expect(event.defaultPrevented).toBe(false)
})
it('yields while a dialog covers the chat, like type-to-focus', () => {
const dialog = document.createElement('div')
dialog.setAttribute('role', 'dialog')
document.body.append(dialog)
const event = pasteEvent(clipboard({ text: 'hi' }))
handleWindowPaste(event)
expect(event.defaultPrevented).toBe(false)
})
it('leaves an already-handled or empty paste alone', () => {
const handled = pasteEvent(clipboard({ text: 'hi' }))
handled.preventDefault()
const before = handled.defaultPrevented
handleWindowPaste(handled)
expect(before).toBe(true)
const empty = pasteEvent(clipboard())
handleWindowPaste(empty)
expect(empty.defaultPrevented).toBe(false)
})
})
@@ -0,0 +1,69 @@
/**
* Paste-to-focus: the paste twin of type-to-focus. A ⌘V/Ctrl+V landing on
* non-editable chrome (the transcript, empty chat, the window body) routes the
* clipboard into the active composer instead of dying on the body — images
* attach, text inserts (links/paths chipping exactly like a focused paste),
* and the composer takes focus so the user keeps typing.
*
* Clipboard data is only readable synchronously inside the paste event, so the
* window listener extracts everything here and the payload rides the composer
* bus. Editable targets keep their own paste handlers, and the same surfaces
* that block type-to-focus (dialogs, menus, terminal, full pages) block this.
*/
import { sanitizeComposerInput } from '@/lib/composer-input-sanitize'
import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images'
import { isEditableTarget } from '@/lib/keybinds/combo'
import { composerFocusBlockedBySurface } from '@/lib/keybinds/composer-focus-keys'
import { requestComposerAttachImages, requestComposerFocus, requestComposerInsert } from './focus'
import { pathifyRefs } from './path-refs'
import { extractClipboardImageBlobs } from './text-utils'
import { linkifyUrls } from './url-refs'
/** Route clipboard contents to the active composer. True when it carried
* something a composer can take (the caller should swallow the event). */
export function routeClipboardToComposer(clipboard: DataTransfer): boolean {
const blobs = extractClipboardImageBlobs(clipboard)
const text = sanitizeComposerInput(clipboard.getData('text').trim())
if (blobs.length > 0) {
requestComposerAttachImages(blobs)
}
// A bare `data:` URL IS the image attached above — not text to insert.
if (text && !DATA_IMAGE_URL_RE.test(text)) {
// Same chipping the focused paste path applies: links land as `@url:`
// chips, bare `@path` tokens promote. The insert focuses the composer.
requestComposerInsert(pathifyRefs(linkifyUrls(text)), { mode: 'inline' })
return true
}
if (blobs.length > 0) {
// Image-only paste: pull focus so the attach lands somewhere visible.
requestComposerFocus('active')
return true
}
return false
}
/** The window-level `paste` dispatcher (use-keybinds registers it beside the
* keydown listener). Yields to editables — they own their own paste — and to
* every surface that owns its keys per type-to-focus. */
export function handleWindowPaste(event: ClipboardEvent) {
if (
event.defaultPrevented ||
!event.clipboardData ||
isEditableTarget(event.target) ||
composerFocusBlockedBySurface()
) {
return
}
if (routeClipboardToComposer(event.clipboardData)) {
event.preventDefault()
}
}
@@ -0,0 +1,103 @@
import type { KeyboardEvent } from 'react'
import { describe, expect, it } from 'vitest'
import { chipTypedPathOnSpace, pathifyRefs } from './path-refs'
import { composerPlainText, RICH_INPUT_SLOT } from './rich-editor'
/**
* Tab-descending into a folder from the `@` popover leaves a bare
* `@apps/desktop/` in the editor. That is not a reference — the backend's
* REFERENCE_PATTERN only matches `@kind:value` — so it used to submit as plain
* text and silently attach nothing.
*/
/** An editor holding `text` with a collapsed caret at its end, plus the space
* keydown the composer would hand `chipTypedPathOnSpace`. */
const spaceOn = (text: string) => {
const editor = document.createElement('div')
editor.dataset.slot = RICH_INPUT_SLOT
editor.textContent = text
document.body.append(editor)
const selection = window.getSelection()!
const range = document.createRange()
range.setStart(editor.firstChild!, text.length)
range.collapse(true)
selection.removeAllRanges()
selection.addRange(range)
return { editor, event: { currentTarget: editor, key: ' ' } as KeyboardEvent<HTMLDivElement> }
}
describe('pathifyRefs', () => {
it('promotes a Tab-descended folder token', () => {
expect(pathifyRefs('look at @apps/desktop/')).toBe('look at @folder:`apps/desktop`')
})
it('promotes a typed file path', () => {
expect(pathifyRefs('read @src/main.ts please')).toBe('read @file:`src/main.ts` please')
})
it('promotes every bare path in one message', () => {
expect(pathifyRefs('@apps/desktop/ and @src/main.ts')).toBe('@folder:`apps/desktop` and @file:`src/main.ts`')
})
it('leaves an already-typed directive alone', () => {
const text = 'look at @folder:`apps/desktop` ok'
expect(pathifyRefs(text)).toBe(text)
})
it('leaves a handle alone', () => {
expect(pathifyRefs('cc @teknium1 on this')).toBe('cc @teknium1 on this')
})
it('leaves simple refs alone', () => {
expect(pathifyRefs('check @diff and @staged')).toBe('check @diff and @staged')
})
it('leaves an email alone', () => {
expect(pathifyRefs('mail me at brooklyn@nous.dev/x')).toBe('mail me at brooklyn@nous.dev/x')
})
it('is a no-op without an @', () => {
expect(pathifyRefs('nothing here')).toBe('nothing here')
})
})
describe('chipTypedPathOnSpace', () => {
it('chips a Tab-descended folder token into a real @folder: chip', () => {
const { editor, event } = spaceOn('look at @apps/desktop/')
expect(chipTypedPathOnSpace(event)).toBe(true)
const chip = editor.querySelector('[data-ref-text]')
expect(chip?.getAttribute('data-ref-kind')).toBe('folder')
expect(chip?.getAttribute('data-ref-id')).toBe('apps/desktop')
expect(chip?.textContent).toContain('desktop')
expect(composerPlainText(editor)).toBe('look at @folder:`apps/desktop` ')
})
it('chips a typed file path', () => {
const { editor, event } = spaceOn('read @src/main.ts')
expect(chipTypedPathOnSpace(event)).toBe(true)
expect(editor.querySelector('[data-ref-kind="file"]')).not.toBeNull()
expect(composerPlainText(editor)).toBe('read @file:`src/main.ts` ')
})
it('leaves a handle alone', () => {
const { editor, event } = spaceOn('cc @teknium1')
expect(chipTypedPathOnSpace(event)).toBe(false)
expect(editor.querySelector('[data-ref-text]')).toBeNull()
})
it('leaves an already-typed ref alone', () => {
const { event } = spaceOn('look at @folder:`apps/desktop`')
expect(chipTypedPathOnSpace(event)).toBe(false)
})
})
@@ -0,0 +1,103 @@
/**
* Bare-path recognition for the composer. Walking into a folder from the `@`
* popover (Tab) re-types the token as a bare `@apps/desktop/` so the next
* `complete.path` lists that folder's children. That's right while typing, but
* a bare token is not a reference: `REFERENCE_PATTERN` only matches
* `@kind:value`, so a draft sent with one attaches nothing and renders as
* plain text instead of a chip.
*
* So the bare token is promoted to its typed form on the way out — the same
* shape `url-refs.ts` uses for bare links.
*/
import type { KeyboardEvent } from 'react'
import { quoteRefValue, REF_RE, refChipElement, replaceBeforeCaret } from './rich-editor'
import { textBeforeCaret } from './text-utils'
// A `/` is required, exactly like URL_RE requires an explicit scheme: `@teknium1`
// and `@diff` are a handle and a simple ref, not paths, and guessing wrong turns
// someone's name into a file reference. A separator is the cheap signal that the
// user meant a path. The token also can't start with a `:` kind prefix — that is
// already a typed ref and REF_RE owns it.
const BARE_PATH_RE = /(?<![\w@/])@((?!(?:file|folder|url|image|tool|line|terminal|session|git):)[^\s@:]*\/[^\s@:]*)/g
const TYPED_BARE_PATH_RE = new RegExp(`${BARE_PATH_RE.source}$`)
/** Trailing `/` means a directory — that's how the gateway's completion emits
* folders, and what Tab-descending leaves behind. */
export function barePathRef(path: string) {
const trimmed = path.replace(/\/+$/, '')
return trimmed ? `@${path.endsWith('/') ? 'folder' : 'file'}:${quoteRefValue(trimmed)}` : null
}
/** Rewrite bare `@path` tokens as typed `@file:`/`@folder:` directives, leaving
* anything already inside a directive alone. Returns `text` unchanged when
* there are none. */
export function pathifyRefs(text: string) {
if (!text.includes('@')) {
return text
}
REF_RE.lastIndex = 0
const fenced = Array.from(text.matchAll(REF_RE)).map(match => {
const start = match.index ?? 0
return { end: start + match[0].length, start }
})
let out = ''
let cursor = 0
for (const match of text.matchAll(BARE_PATH_RE)) {
const start = match.index ?? 0
const ref = barePathRef(match[1] || '')
if (!ref || fenced.some(span => start >= span.start && start < span.end)) {
continue
}
out += `${text.slice(cursor, start)}${ref}`
cursor = start + match[0].length
}
return out + text.slice(cursor)
}
/** A plain space finishing a typed `@path` commits it as a chip, so a hand-typed
* path chips the same way a picked one does. Returns whether it ran, so a
* keydown handler can fall through on anything else. */
export function chipTypedPathOnSpace(event: KeyboardEvent<HTMLDivElement>) {
if (event.key !== ' ' || event.metaKey || event.ctrlKey || event.altKey) {
return false
}
const editor = event.currentTarget
// Runs on every space, so bail on the cheap native read before paying for the
// caret range walk (same guard shape as the trigger detector).
if (!editor.textContent?.includes('@')) {
return false
}
const before = textBeforeCaret(editor)
const match = before ? TYPED_BARE_PATH_RE.exec(before) : null
const token = match?.[0]
const ref = match?.[1] ? barePathRef(match[1]) : null
if (!token || !ref) {
return false
}
const directive = ref.match(/^@([^:]+):(.+)$/)
if (!directive) {
return false
}
const fragment = document.createDocumentFragment()
fragment.append(refChipElement(directive[1], directive[2]), document.createTextNode(' '))
return replaceBeforeCaret(editor, token.length, fragment)
}
@@ -0,0 +1,165 @@
import { StatusRow } from '@/components/chat/status-row'
import { StatusSection } from '@/components/chat/status-section'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { Tip } from '@/components/ui/tooltip'
import { type Translations, useI18n } from '@/i18n'
import { CornerDownLeft, iconSize, Pencil, SteeringWheel, Trash2 } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { isSteerableEntry, type QueuedPromptEntry } from '@/store/composer-queue'
interface QueuePanelProps {
busy: boolean
editingId: null | string
entries: QueuedPromptEntry[]
onDelete: (id: string) => void
onEdit: (entry: QueuedPromptEntry) => void
/** Lift a park (explicit Stop/Esc halt) and let the queue flow again. */
onResume: () => void
onSendNow: (id: string) => void
/** Deliver an entry as a mid-turn redirect (no interrupt). Absent when the
* host has no steer path — the affordance hides rather than dead-clicks. */
onSteerNow?: (id: string) => void
/** True after an explicit halt: entries wait until resumed / sent / edited. */
parked: boolean
}
const entryPreview = (entry: QueuedPromptEntry, c: Translations['composer']) =>
(entry.displayText ?? entry.text).trim() || (entry.attachments.length > 0 ? c.attachmentOnly : c.emptyTurn)
export function QueuePanel({
busy,
editingId,
entries,
onDelete,
onEdit,
onResume,
onSendNow,
onSteerNow,
parked
}: QueuePanelProps) {
const { t } = useI18n()
const c = t.composer
if (entries.length === 0) {
return null
}
return (
// Keyed on the park flag: StatusSection owns its collapse state from
// defaultCollapsed, so remount on park/unpark. A Stop must EXPAND the
// panel — the halted prompts' only presence is here, and leaving them
// behind a collapsed "N queued" pill is how they read as vanished.
<StatusSection
accessory={
parked ? (
<Tip label={c.queueResumeTip}>
<Button
className="text-muted-foreground/75 hover:text-foreground/90"
onClick={onResume}
size="micro"
type="button"
variant="text"
>
{c.queueResume}
</Button>
</Tip>
) : undefined
}
defaultCollapsed={!parked}
icon={<Codicon className="text-muted-foreground/70" name={parked ? 'debug-pause' : 'layers'} size="0.8rem" />}
key={parked ? 'parked' : 'flowing'}
label={parked ? c.queuedPaused(entries.length) : c.queued(entries.length)}
>
{entries.map(entry => {
const isEditing = editingId === entry.id
const attachmentsCount = entry.attachments.length
// Steer only surfaces where it can actually deliver: a live turn to
// redirect and an entry the redirect can carry (text-only, no slash).
const canSteer = busy && Boolean(onSteerNow) && isSteerableEntry(entry)
return (
<StatusRow
className={cn(
'border border-transparent',
isEditing && 'border-[color-mix(in_srgb,var(--dt-composer-ring)_40%,transparent)] bg-accent/25'
)}
key={entry.id}
trailing={
<>
<Tip label={c.queueEdit}>
<Button
aria-label={c.queueEdit}
className="size-5 rounded-md"
disabled={Boolean(editingId) && !isEditing}
onClick={() => onEdit(entry)}
size="icon-xs"
type="button"
variant="ghost"
>
<Pencil className={iconSize.xs} />
</Button>
</Tip>
{canSteer && (
<Tip label={c.queueSteer}>
<Button
aria-label={c.queueSteer}
className="size-5 rounded-md"
disabled={isEditing}
onClick={() => onSteerNow?.(entry.id)}
size="icon-xs"
type="button"
variant="ghost"
>
<SteeringWheel className={iconSize.xs} />
</Button>
</Tip>
)}
<Tip label={busy ? c.queueSendNext : c.queueSend}>
<Button
aria-label={busy ? c.queueSendNext : c.queueSend}
className="size-5 rounded-md"
disabled={isEditing}
onClick={() => onSendNow(entry.id)}
size="icon-xs"
type="button"
variant="ghost"
>
<CornerDownLeft className={iconSize.xs} />
</Button>
</Tip>
<Tip label={c.queueDelete}>
<Button
aria-label={c.queueDelete}
className="size-5 rounded-md"
onClick={() => onDelete(entry.id)}
size="icon-xs"
type="button"
variant="ghost"
>
<Trash2 className={iconSize.xs} />
</Button>
</Tip>
</>
}
trailingVisible={isEditing}
>
<div className="min-w-0 flex-1">
<p className="truncate text-[0.73rem] leading-4 text-foreground/92">{entryPreview(entry, c)}</p>
{(attachmentsCount > 0 || isEditing) && (
<div className="mt-0.5 flex items-center gap-1.5 text-[0.64rem] text-muted-foreground/75">
{attachmentsCount > 0 && <span>{c.attachments(attachmentsCount)}</span>}
{isEditing && (
<span className="text-[color-mix(in_srgb,var(--dt-composer-ring)_78%,var(--muted-foreground))]">
{c.editingInComposer}
</span>
)}
</div>
)}
</div>
</StatusRow>
)
})}
</StatusSection>
)
}
@@ -0,0 +1,385 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { rememberDesktopCommandsCatalog } from '@/lib/desktop-slash-commands'
import { insertInlineRefsIntoEditor } from './inline-refs'
import {
composerPlainText,
deleteSelectionInEditor,
insertComposerContentsAtCaret,
normalizeComposerEditorDom,
refChipElement,
renderComposerContents,
replaceBeforeCaret,
RICH_INPUT_SLOT
} from './rich-editor'
import { placeCaretAtEnd } from './test-utils'
beforeEach(() => {
rememberDesktopCommandsCatalog({
commands: { '/goal': { argument_mode: 'mixed', desktop: null } }
})
})
afterEach(() => {
rememberDesktopCommandsCatalog(undefined)
})
describe('renderComposerContents', () => {
it('renders refs and raw text without interpreting user text as HTML', () => {
const editor = document.createElement('div')
editor.dataset.slot = RICH_INPUT_SLOT
renderComposerContents(editor, '@file:`<img src=x onerror=alert(1)>` <b>raw</b>')
expect(editor.querySelector('img')).toBeNull()
expect(editor.querySelector('b')).toBeNull()
expect(editor.textContent).toContain('<img src=x onerror=alert(1)>')
expect(editor.textContent).toContain('<b>raw</b>')
expect(composerPlainText(editor)).toBe('@file:`<img src=x onerror=alert(1)>` <b>raw</b>')
})
it('hydrates a committed leading slash command back to its pill', () => {
// Text-hydration parity with @ refs: a re-render from serialized text
// (draft restore, undo, the trigger commit fallback) must not demote a
// committed no-arg command chip to plain text.
const editor = document.createElement('div')
editor.dataset.slot = RICH_INPUT_SLOT
renderComposerContents(editor, '/some-skill @folder:`Desktop` ')
const pill = editor.querySelector('[data-slash-kind]')
expect(pill?.getAttribute('data-ref-text')).toBe('/some-skill')
expect(editor.querySelector('[data-ref-kind="folder"]')).not.toBeNull()
expect(composerPlainText(editor)).toBe('/some-skill @folder:`Desktop` ')
})
it('keeps a still-typed leading slash token as editable text', () => {
const editor = document.createElement('div')
editor.dataset.slot = RICH_INPUT_SLOT
// No trailing whitespace — not committed yet.
renderComposerContents(editor, '/some-skil')
expect(editor.querySelector('[data-slash-kind]')).toBeNull()
expect(composerPlainText(editor)).toBe('/some-skil')
})
it('keeps an arg-taking command as text — its tail may be uncommitted prose', () => {
const editor = document.createElement('div')
editor.dataset.slot = RICH_INPUT_SLOT
renderComposerContents(editor, '/goal ship the redesign')
expect(editor.querySelector('[data-slash-kind]')).toBeNull()
expect(composerPlainText(editor)).toBe('/goal ship the redesign')
})
})
describe('replaceBeforeCaret across split text nodes', () => {
it('replaces a token that Chromium fragmented into multiple text nodes', () => {
const editor = document.createElement('div')
editor.dataset.slot = RICH_INPUT_SLOT
editor.contentEditable = 'true'
document.body.append(editor)
editor.append(document.createTextNode('see @Desk'), document.createTextNode('top/'))
const caret = document.createRange()
caret.setStart(editor.lastChild!, 4)
caret.collapse(true)
const selection = window.getSelection()!
selection.removeAllRanges()
selection.addRange(caret)
const fragment = document.createDocumentFragment()
fragment.append(refChipElement('folder', '`Desktop`'), document.createTextNode(' '))
// Token `@Desktop/` (9 chars) spans both text nodes.
expect(replaceBeforeCaret(editor, 9, fragment)).toBe(true)
expect(composerPlainText(editor)).toBe('see @folder:`Desktop` ')
editor.remove()
})
it('refuses when a chip interrupts the span — the token is not contiguous text', () => {
const editor = document.createElement('div')
editor.dataset.slot = RICH_INPUT_SLOT
editor.contentEditable = 'true'
document.body.append(editor)
editor.append(document.createTextNode('a'), refChipElement('file', '`x`'), document.createTextNode('bc'))
const caret = document.createRange()
caret.setStart(editor.lastChild!, 2)
caret.collapse(true)
const selection = window.getSelection()!
selection.removeAllRanges()
selection.addRange(caret)
expect(replaceBeforeCaret(editor, 5, document.createDocumentFragment())).toBe(false)
expect(composerPlainText(editor)).toBe('a@file:`x`bc')
editor.remove()
})
})
describe('normalizeComposerEditorDom', () => {
it('unwraps a single insertHTML wrapper div so plain text stays one line', () => {
const editor = document.createElement('div')
editor.dataset.slot = RICH_INPUT_SLOT
editor.innerHTML = '<div><span data-ref-text="@file:`src/foo.ts`" contenteditable="false">foo.ts</span> </div>'
normalizeComposerEditorDom(editor)
expect(composerPlainText(editor)).toBe('@file:`src/foo.ts` ')
expect(editor.querySelector(':scope > div')).toBeNull()
})
it('removes a trailing br after a ref chip', () => {
const editor = document.createElement('div')
editor.dataset.slot = RICH_INPUT_SLOT
editor.append(refChipElement('file', '`src/foo.ts`'), document.createElement('br'))
normalizeComposerEditorDom(editor)
expect(composerPlainText(editor)).toBe('@file:`src/foo.ts`')
expect(editor.querySelector('br')).toBeNull()
})
})
describe('insertInlineRefsIntoEditor', () => {
it('inserts chips without wrapper divs or spurious newlines', () => {
const editor = document.createElement('div')
editor.dataset.slot = RICH_INPUT_SLOT
insertInlineRefsIntoEditor(editor, ['@file:`src/foo.ts`'])
expect(editor.querySelector(':scope > div')).toBeNull()
expect(composerPlainText(editor)).toBe('@file:`src/foo.ts` ')
})
it('separates a chip from the word the caret sits after', () => {
const editor = document.createElement('div')
editor.dataset.slot = RICH_INPUT_SLOT
editor.append(document.createTextNode('review'))
document.body.append(editor)
placeCaretAtEnd(editor)
expect(insertInlineRefsIntoEditor(editor, ['@file:`src/a.ts`'])).toBe('review @file:`src/a.ts` ')
editor.remove()
})
it('does not double the space when one is already there', () => {
const editor = document.createElement('div')
editor.dataset.slot = RICH_INPUT_SLOT
editor.append(document.createTextNode('review '))
document.body.append(editor)
placeCaretAtEnd(editor)
expect(insertInlineRefsIntoEditor(editor, ['@file:`src/a.ts`'])).toBe('review @file:`src/a.ts` ')
editor.remove()
})
})
describe('insertComposerContentsAtCaret', () => {
it('inserts multiline text as text nodes + br', () => {
const editor = document.createElement('div')
editor.dataset.slot = RICH_INPUT_SLOT
document.body.append(editor)
placeCaretAtEnd(editor)
insertComposerContentsAtCaret(editor, 'one\ntwo\nthree')
expect(editor.querySelectorAll('br').length).toBe(2)
expect(composerPlainText(editor)).toBe('one\ntwo\nthree')
editor.remove()
})
it('replaces the selected span', () => {
const editor = document.createElement('div')
editor.dataset.slot = RICH_INPUT_SLOT
editor.textContent = 'abXYef'
document.body.append(editor)
const text = editor.firstChild!
const selection = window.getSelection()!
const range = document.createRange()
range.setStart(text, 2)
range.setEnd(text, 4)
selection.removeAllRanges()
selection.addRange(range)
insertComposerContentsAtCaret(editor, 'cd')
expect(composerPlainText(editor)).toBe('abcdef')
editor.remove()
})
it('lands directives in the text as chips', () => {
const editor = document.createElement('div')
editor.dataset.slot = RICH_INPUT_SLOT
document.body.append(editor)
placeCaretAtEnd(editor)
insertComposerContentsAtCaret(editor, 'read @url:`https://example.dev/a` now')
expect(editor.querySelectorAll('[data-ref-kind="url"]').length).toBe(1)
expect(composerPlainText(editor)).toBe('read @url:`https://example.dev/a` now')
editor.remove()
})
// A directive typed by hand chips; the same directive pasted has to chip too,
// or copy/pasting a prompt silently drops every command in it.
it('chips a pasted slash command, including one that ends the paste', () => {
const editor = document.createElement('div')
editor.dataset.slot = RICH_INPUT_SLOT
document.body.append(editor)
placeCaretAtEnd(editor)
insertComposerContentsAtCaret(editor, '/some-skill')
expect(editor.querySelector('[data-slash-kind]')?.getAttribute('data-ref-text')).toBe('/some-skill')
// Committed pills carry the trailing space the typed path appends, so a
// later full re-render doesn't read the token as half-typed.
expect(composerPlainText(editor)).toBe('/some-skill ')
editor.remove()
})
it('chips a skill named mid-paste alongside a ref', () => {
const editor = document.createElement('div')
editor.dataset.slot = RICH_INPUT_SLOT
document.body.append(editor)
placeCaretAtEnd(editor)
insertComposerContentsAtCaret(editor, 'clean @file:`a.ts` with /some-skill then ship')
expect(editor.querySelectorAll('[data-slash-kind]').length).toBe(1)
expect(editor.querySelectorAll('[data-ref-kind="file"]').length).toBe(1)
expect(composerPlainText(editor)).toBe('clean @file:`a.ts` with /some-skill then ship')
editor.remove()
})
it('leaves a pasted path alone — /usr/local is not a command', () => {
const editor = document.createElement('div')
editor.dataset.slot = RICH_INPUT_SLOT
document.body.append(editor)
placeCaretAtEnd(editor)
insertComposerContentsAtCaret(editor, 'see /usr/local/bin and /goal ship it')
expect(editor.querySelector('[data-slash-kind]')).toBeNull()
expect(composerPlainText(editor)).toBe('see /usr/local/bin and /goal ship it')
editor.remove()
})
it('does not chip a command pasted against a word — foo/clean is not a command', () => {
const editor = document.createElement('div')
editor.dataset.slot = RICH_INPUT_SLOT
editor.textContent = 'foo'
document.body.append(editor)
placeCaretAtEnd(editor)
insertComposerContentsAtCaret(editor, '/some-skill')
expect(editor.querySelector('[data-slash-kind]')).toBeNull()
expect(composerPlainText(editor)).toBe('foo/some-skill')
editor.remove()
})
it('chips a command pasted right after an existing chip', () => {
const editor = document.createElement('div')
editor.dataset.slot = RICH_INPUT_SLOT
editor.append(refChipElement('file', '`a.ts`'))
document.body.append(editor)
placeCaretAtEnd(editor)
insertComposerContentsAtCaret(editor, '/some-skill')
expect(editor.querySelector('[data-slash-kind]')).not.toBeNull()
editor.remove()
})
})
describe('replaceBeforeCaret', () => {
it('swaps the token before the caret and leaves the caret after the insert', () => {
const editor = document.createElement('div')
editor.dataset.slot = RICH_INPUT_SLOT
editor.textContent = 'see foo'
document.body.append(editor)
const text = editor.firstChild!
const selection = window.getSelection()!
const range = document.createRange()
range.setStart(text, 7)
range.collapse(true)
selection.removeAllRanges()
selection.addRange(range)
const fragment = document.createDocumentFragment()
fragment.append(refChipElement('file', '`src/foo.ts`'), document.createTextNode(' '))
expect(replaceBeforeCaret(editor, 3, fragment)).toBe(true)
expect(composerPlainText(editor)).toBe('see @file:`src/foo.ts` ')
expect(selection.getRangeAt(0).collapsed).toBe(true)
editor.remove()
})
it('leaves the editor alone when the caret has no room for the token', () => {
const editor = document.createElement('div')
editor.dataset.slot = RICH_INPUT_SLOT
editor.textContent = 'hi'
document.body.append(editor)
const selection = window.getSelection()!
const range = document.createRange()
range.setStart(editor.firstChild!, 2)
range.collapse(true)
selection.removeAllRanges()
selection.addRange(range)
const fragment = document.createDocumentFragment()
fragment.append(document.createTextNode('x'))
expect(replaceBeforeCaret(editor, 20, fragment)).toBe(false)
expect(composerPlainText(editor)).toBe('hi')
editor.remove()
})
})
describe('deleteSelectionInEditor', () => {
it('clears a non-collapsed range and leaves a collapsed caret', () => {
const editor = document.createElement('div')
editor.dataset.slot = RICH_INPUT_SLOT
editor.textContent = 'hello world'
document.body.append(editor)
const selection = window.getSelection()!
const range = document.createRange()
range.selectNodeContents(editor)
selection.removeAllRanges()
selection.addRange(range)
expect(deleteSelectionInEditor(editor)).toBe(true)
expect(composerPlainText(editor)).toBe('')
expect(selection.getRangeAt(0).collapsed).toBe(true)
expect(deleteSelectionInEditor(editor)).toBe(false)
editor.remove()
})
})
@@ -0,0 +1,774 @@
/**
* Helpers for the contenteditable composer surface: serialize refs to chip
* HTML, walk the DOM back to plain `@kind:value` text, and place the caret.
*
* Chip values are always wrapped in backticks/quotes so REF_RE stops at the
* fence — without that, typing after a chip would get re-absorbed on the next
* plain-text round-trip.
*/
import {
directiveIconElement,
directiveIconSvg,
formatRefValue,
refAttrsHtml,
refChipLabel,
type SlashChipKind,
slashIconElement
} from '@/components/assistant-ui/directive-text'
import { referenceKind, referenceRe } from '@/components/assistant-ui/reference-kinds'
import { slashCommandMatches, type SlashCommandScanOptions } from './slash-refs'
export const RICH_INPUT_SLOT = 'composer-rich-input'
/** Chromium's litter: editing beside a `contenteditable=false` chip splits the
* line and leaves zero-length text nodes behind. They render as nothing and
* serialize as nothing, so no reader of the editor should count them. */
function isEmptyTextNode(node: ChildNode | null): boolean {
return node?.nodeType === Node.TEXT_NODE && !node.textContent
}
/** The node before `node`, stepping over that litter. */
function meaningfulPreviousSibling(node: ChildNode | null): ChildNode | null {
let prev = node?.previousSibling ?? null
while (isEmptyTextNode(prev)) {
prev = prev?.previousSibling ?? null
}
return prev
}
/** The node after `node`, stepping over that litter. */
function meaningfulNextSibling(node: ChildNode | null): ChildNode | null {
let next = node?.nextSibling ?? null
while (isEmptyTextNode(next)) {
next = next?.nextSibling ?? null
}
return next
}
/** Keep the `data-empty` marker the placeholder paints on in step with the
* editor root's contents.
*
* `:empty` can't be the whole test: a cleared editor keeps a scaffolding <br>
* so the contenteditable doesn't collapse, and that break makes `:empty`
* false. Nor can CSS infer it on its own — a text node is invisible to
* selectors, so `one<br>` and a lone `<br>` are the same shape, and
* `:has(> br:only-child)` would paint the placeholder over the user's text.
* The code that empties the editor is what knows, so it marks it.
*
* Zero-length text nodes don't count as contents. Chromium leaves them behind
* whenever an edit lands next to a `contenteditable=false` chip, and counting
* them left an editor the user had emptied looking occupied. */
export function markEditorEmptiness(editor: HTMLElement) {
if (Array.from(editor.childNodes).every(isEmptyTextNode)) {
editor.dataset.empty = ''
} else {
delete editor.dataset.empty
}
}
/** Drop the marker as IME composition starts, before any preedit text lands.
*
* Input events during composition are deliberately skipped (they carry
* uncommitted preedit text), so nothing else clears the marker until
* `compositionend` — and the hint would otherwise sit behind the hiragana the
* user is composing. `normalizeComposerEditorDom` restores it if composition
* ends with nothing committed. */
export function beginComposerComposition(editor: HTMLElement) {
delete editor.dataset.empty
}
/** @see referenceRe — the shared pattern every surface recognises a reference
* with. Module-level `/g` regexes carry `lastIndex`, so call sites reset it. */
export const REF_RE = referenceRe()
const ESC: Record<string, string> = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#039;' }
export function escapeHtml(value: string) {
return value.replace(/[&<>"']/g, ch => ESC[ch] || ch)
}
export function unquoteRef(raw: string) {
const head = raw[0]
const tail = raw[raw.length - 1]
const quoted = (head === '`' && tail === '`') || (head === '"' && tail === '"') || (head === "'" && tail === "'")
return quoted ? raw.slice(1, -1) : raw.replace(/[,.;!?]+$/, '')
}
/** Always-quote variant of formatRefValue — chips need a fence even for safe values. */
export function quoteRefValue(value: string) {
if (!value.includes('`')) {
return `\`${value}\``
}
if (!value.includes('"')) {
return `"${value}"`
}
if (!value.includes("'")) {
return `'${value}'`
}
return formatRefValue(value)
}
export function refChipHtml(kind: string, rawValue: string, displayLabel?: string) {
const id = unquoteRef(rawValue)
const text = `@${kind}:${quoteRefValue(id)}`
const label = displayLabel || refChipLabel(kind, id)
return `<span contenteditable="false" title="${escapeHtml(id)}" data-ref-text="${escapeHtml(text)}" data-ref-id="${escapeHtml(id)}" data-ref-kind="${escapeHtml(kind)}" ${refAttrsHtml(kind)}>${directiveIconSvg(kind)}${escapeHtml(label)}</span>`
}
export function refChipElement(kind: string, rawValue: string, displayLabel?: string) {
const id = unquoteRef(rawValue)
const text = `@${kind}:${quoteRefValue(id)}`
const chip = document.createElement('span')
chip.contentEditable = 'false'
chip.title = id
chip.dataset.refText = text
chip.dataset.refId = id
chip.dataset.refKind = kind
chip.className = 'ref'
chip.dataset.ref = referenceKind(kind)
chip.append(directiveIconElement(kind), document.createTextNode(displayLabel || refChipLabel(kind, id)))
return chip
}
/** A non-editable reference for a picked slash command (`/skin nous`, `/tropes`).
* `data-ref-text` carries the literal command so `composerPlainText` round-trips
* it back to the exact text that gets submitted. */
export function slashChipElement(command: string, kind: SlashChipKind, label?: string) {
const chip = document.createElement('span')
chip.contentEditable = 'false'
chip.dataset.refText = command
chip.dataset.slashKind = kind
chip.className = 'ref'
chip.dataset.ref = kind
chip.append(slashIconElement(kind), document.createTextNode(label || command))
return chip
}
function appendTextWithBreaks(target: DocumentFragment | HTMLElement, text: string) {
const lines = text.split('\n')
lines.forEach((line, index) => {
if (index > 0) {
target.append(document.createElement('br'))
}
if (line) {
target.append(document.createTextNode(line))
}
})
}
/** Every span of `text` that renders as a chip, in source order. */
function chipSpans(text: string, options: SlashCommandScanOptions) {
REF_RE.lastIndex = 0
const refs = Array.from(text.matchAll(REF_RE)).map(match => {
const start = match.index ?? 0
return { end: start + match[0].length, node: () => refChipElement(match[1] || 'file', match[2] || ''), start }
})
const commands = slashCommandMatches(text, options).map(match => ({
end: match.end,
node: () => slashChipElement(match.command, match.kind),
start: match.start
}))
return [...refs, ...commands].sort((a, b) => a.start - b.start)
}
/** Build the chip/text DOM for `text`. Directives hydrate back to their pills —
* `@kind:value` refs and `/command` invocations both — so text that arrives
* whole (a paste, a restored draft, an undo step, a rebuilt line) carries the
* same chips the typed path would have committed. */
export function appendComposerContents(
target: DocumentFragment | HTMLElement,
text: string,
options: SlashCommandScanOptions = {}
) {
let cursor = 0
for (const span of chipSpans(text, options)) {
// A `@` ref wins an overlap: a command token can't contain an `@`, so the
// only way spans collide is a slash inside a quoted ref value
// (`` @url:`a /clean` ``), which belongs to that value.
if (span.start < cursor) {
continue
}
appendTextWithBreaks(target, text.slice(cursor, span.start))
target.append(span.node())
cursor = span.end
}
appendTextWithBreaks(target, text.slice(cursor))
}
export function renderComposerContents(target: HTMLElement, text: string, options?: SlashCommandScanOptions) {
target.replaceChildren()
// Defaults to live editing, where a token ending the text is still being
// typed (`/wor`) and must stay editable. Callers repainting inert text (a
// restored draft, a sent message opened for edit) pass `trailingCommitted`.
appendComposerContents(target, text, options)
// The other writer that reshapes the editor root: painting a restored draft
// in clears the marker, clearing back to '' sets it.
markEditorEmptiness(target)
}
/** Caret range when the selection lives inside `editor`; else null. */
function composerSelectionRange(editor: HTMLElement) {
const selection = window.getSelection()
const range = selection?.rangeCount ? selection.getRangeAt(0) : null
if (!selection || !range || !editor.contains(range.commonAncestorContainer)) {
return null
}
return { range, selection }
}
/** Serialized text from the editor's start up to (`container`, `offset`).
*
* Chips are ATOMIC here: each contributes an object-replacement placeholder
* rather than leaking its label text, and a <br> contributes a newline. That
* makes a chip edge read as a token boundary, which is what both trigger
* detection and directive recognition need. */
export function serializeTextBefore(editor: HTMLElement, container: Node, offset: number): string {
const probe = document.createRange()
probe.selectNodeContents(editor)
probe.setEnd(container, offset)
const scratch = document.createElement('div')
scratch.append(probe.cloneContents())
for (const chip of scratch.querySelectorAll('[data-ref-text]')) {
chip.replaceWith('\uFFFC')
}
for (const br of scratch.querySelectorAll('br')) {
br.replaceWith('\n')
}
return scratch.textContent ?? ''
}
/** True when the insertion point starts a token — the editor's start, or after
* whitespace or a chip. `foo` + a pasted `/clean` is `foo/clean`, not a
* command; `foo ` + the same paste is. */
function atTokenBoundary(editor: HTMLElement, range: Range | null): boolean {
// No caret means the insert lands at the end, so the question is about the
// editor's last character either way.
const before = range
? serializeTextBefore(editor, range.startContainer, range.startOffset)
: serializeTextBefore(editor, editor, editor.childNodes.length)
const last = before.slice(-1)
return !last || /[\s\uFFFC]/.test(last)
}
/** Insert text at the caret (replacing any selection), with any directives in
* it landing as chips. Pastes use this instead of `execCommand('insertText')`
* — Chromium's editing pipeline is ~O(n²) on large multiline blobs.
*
* The text arrives whole rather than typed, so a `/command` ending it is
* complete rather than half-written and chips like the rest.
*
* `consumeBefore` characters immediately before the caret are swallowed by the
* insert. That's how a paste into an open `@url:` scope replaces the scope
* instead of stacking on it (`@url:@url:\`https://…\``). */
export function insertComposerContentsAtCaret(editor: HTMLElement, text: string, consumeBefore = 0) {
const scoped = consumeBefore > 0 ? rangeBeforeCaret(editor, consumeBefore) : null
if (scoped) {
scoped.deleteContents()
scoped.collapse(true)
const selection = window.getSelection()
selection?.removeAllRanges()
selection?.addRange(scoped)
}
const hit = composerSelectionRange(editor)
const fragment = document.createDocumentFragment()
// Before measuring the boundary — a replaced selection puts the insertion
// point where the selection started, not where it ended.
if (hit) {
hit.range.deleteContents()
}
appendComposerContents(fragment, text, {
boundaryBefore: atTokenBoundary(editor, hit?.range ?? null),
trailingCommitted: true
})
// A slash pill ending the insert gets the trailing space the typed commit
// path appends, or the next full re-render reads it as a half-typed token
// and demotes it. `@` refs need no marker — REF_RE re-chips them either way.
if ((fragment.lastChild as HTMLElement | null)?.dataset?.slashKind) {
fragment.append(document.createTextNode(' '))
}
const tail = fragment.lastChild
if (hit) {
hit.range.insertNode(fragment)
} else {
editor.append(fragment)
}
if (tail) {
const caret = document.createRange()
caret.setStartAfter(tail)
caret.collapse(true)
const selection = hit?.selection ?? window.getSelection()
selection?.removeAllRanges()
selection?.addRange(caret)
}
}
/** Range covering exactly `length` serialized characters immediately before a
* collapsed caret, spanning Chromium's split text nodes. Null when the caret
* isn't a collapsed selection in `editor`, or when a chip/<br>/block boundary
* interrupts before `length` characters are covered — a trigger token is
* always contiguous text, so anything else means "don't touch the DOM here".
*
* This is what keeps chip insertion stable: Chromium fragments text nodes
* around contenteditable=false chips on every edit, so any commit path that
* demands the whole token inside ONE text node (the old check) degrades to a
* full re-render as soon as a chip exists anywhere in the line. */
export function rangeBeforeCaret(editor: HTMLElement, length: number): Range | null {
const hit = composerSelectionRange(editor)
if (!hit?.range.collapsed || length <= 0) {
return null
}
let node: Node | null = hit.range.startContainer
let offset = hit.range.startOffset
// An element-positioned caret (common right after programmatic caret moves)
// resolves to the end of the text node before it. A chip or <br> there means
// no text token precedes the caret — bail rather than guess.
if (node.nodeType !== Node.TEXT_NODE) {
node = node.childNodes[offset - 1] ?? null
if (node?.nodeType !== Node.TEXT_NODE) {
return null
}
offset = (node.textContent || '').length
}
let startNode = node as Text
let startOffset = offset
let remaining = length
while (remaining > 0) {
if (startOffset >= remaining) {
startOffset -= remaining
remaining = 0
break
}
remaining -= startOffset
const prev: Node | null = startNode.previousSibling
if (prev?.nodeType !== Node.TEXT_NODE) {
return null
}
startNode = prev as Text
startOffset = (prev.textContent || '').length
}
const range = document.createRange()
range.setStart(startNode, startOffset)
range.setEnd(hit.range.startContainer, hit.range.startOffset)
return range
}
/** Swap the `length` characters immediately before a collapsed caret for
* `fragment`, leaving the caret after it. Returns whether it ran. Spans split
* text nodes (see rangeBeforeCaret) — a token typed around existing chips
* still commits in place instead of falling back to a full re-render. */
export function replaceBeforeCaret(editor: HTMLElement, length: number, fragment: DocumentFragment) {
const range = rangeBeforeCaret(editor, length)
if (!range) {
return false
}
const tail = fragment.lastChild
range.deleteContents()
range.insertNode(fragment)
if (tail) {
range.setStartAfter(tail)
}
range.collapse(true)
const selection = window.getSelection()
selection?.removeAllRanges()
selection?.addRange(range)
return true
}
/** Backspace at a collapsed caret immediately after a chip: delete the chip AND
* the single trailing space we auto-insert after it, atomically — so removing a
* directive never strands an orphaned space (the contenteditable-driven cleanup
* was unreliable). Returns whether it ran.
*
* "Immediately after" has to be read through Chromium's litter. Committing a
* completion empties the typed token's text node rather than removing it, and
* `Range.insertNode` splits around the caret, so the chip routinely sits
* between zero-length text nodes. Reading those as content made the caret look
* like it was after plain text; the delete declined and Chromium's own
* backspace bounced between the leftovers instead of removing the chip. */
export function deleteChipBeforeCaret(editor: HTMLElement): boolean {
const hit = composerSelectionRange(editor)
if (!hit || !hit.range.collapsed) {
return false
}
const { startContainer, startOffset } = hit.range
let chip: ChildNode | null = null
if (startContainer === editor) {
chip = startOffset > 0 ? (editor.childNodes[startOffset - 1] ?? null) : null
if (isEmptyTextNode(chip)) {
chip = meaningfulPreviousSibling(chip)
}
} else if (startContainer.nodeType === Node.TEXT_NODE && startOffset === 0) {
chip = meaningfulPreviousSibling(startContainer as ChildNode)
}
if (chip?.nodeType !== Node.ELEMENT_NODE || !(chip as HTMLElement).dataset.refText) {
return false
}
const after = meaningfulNextSibling(chip)
chip.remove()
// Drop the auto-inserted trailing space; keep any real following text.
if (after?.nodeType === Node.TEXT_NODE) {
const text = after.textContent ?? ''
if (text === ' ') {
after.remove()
} else if (text.startsWith(' ')) {
after.textContent = text.slice(1)
}
}
const caret = document.createRange()
if (after?.isConnected) {
caret.setStartBefore(after)
} else {
caret.selectNodeContents(editor)
caret.collapse(false)
}
caret.collapse(true)
hit.selection.removeAllRanges()
hit.selection.addRange(caret)
return true
}
/** Remove a non-collapsed selection in-editor. Skips collapsed carets so word/
* line delete (Opt/Cmd+Backspace) stays native. Returns whether anything ran. */
export function deleteSelectionInEditor(editor: HTMLElement) {
const hit = composerSelectionRange(editor)
if (!hit || hit.range.collapsed) {
return false
}
hit.range.deleteContents()
hit.range.collapse(true)
hit.selection.removeAllRanges()
hit.selection.addRange(hit.range)
return true
}
/** Serialize a draft string into chip-HTML for the contenteditable surface. */
export function composerHtml(text: string) {
let cursor = 0
let html = ''
REF_RE.lastIndex = 0
for (const match of text.matchAll(REF_RE)) {
const index = match.index ?? 0
html += escapeHtml(text.slice(cursor, index)).replace(/\n/g, '<br>')
html += refChipHtml(match[1] || 'file', match[2] || '')
cursor = index + match[0].length
}
return html + escapeHtml(text.slice(cursor)).replace(/\n/g, '<br>')
}
/** Walk a DOM subtree back to the plain `@kind:value` text it represents. */
export function composerPlainText(node: Node): string {
if (node.nodeType === Node.TEXT_NODE) {
return node.textContent || ''
}
if (node.nodeType !== Node.ELEMENT_NODE) {
return ''
}
const el = node as HTMLElement
if (el.dataset.refText) {
return el.dataset.refText
}
// An editor holding nothing but the placeholder <br> is EMPTY. That <br> is
// scaffolding normalizeComposerEditorDom adds so the contenteditable keeps
// its height — not a line the user typed. Reading it as "\n" is how a
// just-cleared composer stayed non-empty: the newline got stashed as the
// session's draft and painted back on return.
if (el.dataset.slot === RICH_INPUT_SLOT && el.childNodes.length === 1 && el.firstChild?.nodeName === 'BR') {
return ''
}
if (el.tagName === 'BR') {
return '\n'
}
const text = Array.from(node.childNodes).map(composerPlainText).join('')
const block = el.tagName === 'DIV' || el.tagName === 'P'
return block && text && el.dataset.slot !== RICH_INPUT_SLOT ? `${text}\n` : text
}
export function placeCaretEnd(element: HTMLElement) {
const range = document.createRange()
const selection = window.getSelection()
range.selectNodeContents(element)
range.collapse(false)
selection?.removeAllRanges()
selection?.addRange(range)
}
/** The caret's offset in `composerPlainText` coordinates, so it can be restored
* after the editor is re-rendered from text (undo/redo). A chip counts as its
* whole `@kind:value` text — the same units the snapshot measures. */
export function caretOffsetInEditor(editor: HTMLElement): number {
const selection = window.getSelection()
const range = selection?.rangeCount ? selection.getRangeAt(0) : null
if (!range || !editor.contains(range.commonAncestorContainer)) {
return composerPlainText(editor).length
}
const before = range.cloneRange()
before.selectNodeContents(editor)
before.setEnd(range.startContainer, range.startOffset)
// The scratch container must carry the editor's slot marker: composerPlainText
// appends a trailing "\n" to any other block element, which would inflate
// every offset by one and land the restored caret a character late.
const container = document.createElement('div')
container.dataset.slot = RICH_INPUT_SLOT
container.append(before.cloneContents())
return composerPlainText(container).length
}
/** Place the caret `offset` characters into the editor, in the same
* `composerPlainText` coordinates `caretOffsetInEditor` reports. Lands after a
* chip it would otherwise split, since a chip is a single atomic unit. */
export function placeCaretAtOffset(editor: HTMLElement, offset: number) {
const selection = window.getSelection()
if (!selection) {
return
}
let remaining = offset
const walk = (node: Node): Range | null => {
for (const child of Array.from(node.childNodes)) {
if (child.nodeType === Node.TEXT_NODE) {
const length = (child.textContent || '').length
if (remaining <= length) {
const range = document.createRange()
range.setStart(child, remaining)
range.collapse(true)
return range
}
remaining -= length
continue
}
if (child.nodeType !== Node.ELEMENT_NODE) {
continue
}
const el = child as HTMLElement
// Chips and <br> are atomic: consume their serialized length whole.
if (el.dataset.refText || el.tagName === 'BR') {
const length = el.dataset.refText ? el.dataset.refText.length : 1
if (remaining < length) {
const range = document.createRange()
range.setStartBefore(el)
range.collapse(true)
return range
}
remaining -= length
continue
}
const hit = walk(el)
if (hit) {
return hit
}
}
return null
}
const range = walk(editor)
if (range) {
selection.removeAllRanges()
selection.addRange(range)
return
}
placeCaretEnd(editor)
}
/** Nothing but a break / whitespace (recursively) — i.e. no real text or chip. */
function isBlankNode(node: ChildNode | null): boolean {
if (!node) {
return false
}
if (node.nodeName === 'BR') {
return true
}
if (node.nodeType === Node.TEXT_NODE) {
return !(node.textContent || '').trim()
}
if (node.nodeType === Node.ELEMENT_NODE) {
const el = node as HTMLElement
return !el.dataset.refText && Array.from(el.childNodes).every(isBlankNode)
}
return false
}
/** Drop contenteditable junk that serializes as `\n` and falsely expands the
* composer. Editing around a contenteditable=false chip makes Chromium wrap the
* remainder in stray block <div>s / trailing <br>s — none of which our own
* rendering emits (we use text nodes + <br> + chips). Real <br> line breaks
* (Shift+Enter, which sit after actual text) are preserved. */
export function normalizeComposerEditorDom(editor: HTMLElement) {
// Chromium's zero-length text nodes first: every check below reads siblings,
// and litter between them makes a chip look like it has text either side.
for (const child of Array.from(editor.childNodes)) {
if (isEmptyTextNode(child)) {
child.remove()
}
}
// A trailing block wrapper holding only a break/whitespace is the phantom
// "new line" Chromium adds after a chip on backspace — drop it.
const tailBlock = editor.lastChild as HTMLElement | null
if (
tailBlock?.nodeType === Node.ELEMENT_NODE &&
(tailBlock.tagName === 'DIV' || tailBlock.tagName === 'P') &&
isBlankNode(tailBlock)
) {
editor.removeChild(tailBlock)
}
// Unwrap a lone block wrapper back to inline content.
if (editor.childNodes.length === 1 && editor.firstChild?.nodeType === Node.ELEMENT_NODE) {
const wrapper = editor.firstChild as HTMLElement
if ((wrapper.tagName === 'DIV' || wrapper.tagName === 'P') && wrapper.dataset.slot !== RICH_INPUT_SLOT) {
editor.replaceChildren(...Array.from(wrapper.childNodes))
}
}
// A trailing <br> right after a chip / only whitespace is a phantom line.
const last = editor.lastChild
if (last?.nodeName === 'BR') {
let prev: ChildNode | null = last.previousSibling
while (prev?.nodeType === Node.TEXT_NODE && !(prev.textContent || '').trim()) {
prev = prev.previousSibling
}
if (!prev || (prev as HTMLElement).dataset?.refText) {
editor.removeChild(last)
}
}
// ContentEditable elements with no children can visually collapse to
// near-zero height in some browsers (especially Chromium), causing the
// composer to appear as a tiny dot/pixel. Ensure there's always at least
// one <br> so the element maintains intrinsic height. The CSS min-height
// is a belt; the <br> is suspenders — together they prevent the shrink.
// That break is also why emptiness has to be marked, not inferred.
markEditorEmptiness(editor)
if (editor.childNodes.length === 0) {
editor.appendChild(document.createElement('br'))
}
}
@@ -0,0 +1,57 @@
import type { ReadableAtom } from 'nanostores'
import { createContext, useContext } from 'react'
import type { ChatMessage } from '@/lib/chat-messages'
import { type ComposerAttachmentScope, mainComposerScope } from '@/store/composer'
import { $activeSessionAwaitingInput } from '@/store/prompts'
import { $messages } from '@/store/session'
import type { ComposerTarget } from './focus'
/**
* COMPOSER SCOPE — which live composer a ChatBar instance IS. The main chat's
* ChatBar runs in the default scope (module-level attachment atom, focus-bus
* target 'main', the active session's awaiting-input edge). A session tile
* mounts its ChatBar under its own scope, so N composers coexist: separate
* attachment chips, separate focus/insert routing, separate Esc semantics.
*
* Draft TEXT needs no scoping — it lives in each ChatBar's contentEditable +
* draftRef and stashes per session key (`stashSessionDraft`), which already
* differs per surface.
*/
export interface ComposerScope {
/** This scope's "turn parked on user input" edge — gates Esc-to-stop. */
$awaitingInput: ReadableAtom<boolean>
attachments: ComposerAttachmentScope
/** 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<ChatMessage[]>
/** Focus-bus routing key (`'main'` | `'tile:<id>'`). */
target: ComposerTarget
}
export const MAIN_COMPOSER_SCOPE: ComposerScope = {
$awaitingInput: $activeSessionAwaitingInput,
$messages,
attachments: mainComposerScope,
target: 'main'
}
const ComposerScopeContext = createContext<ComposerScope>(MAIN_COMPOSER_SCOPE)
export const ComposerScopeProvider = ComposerScopeContext.Provider
export const useComposerScope = (): ComposerScope => useContext(ComposerScopeContext)
/**
* Unique identity for one mounted ChatView/composer pair. Session ids cannot
* fill this role: a fresh chat has no id yet, and the same stored session can
* be rendered in more than one layout pane. External submit requests pin this
* surface id at click time so exactly one composer can claim the task.
*/
const ComposerSurfaceContext = createContext<string | null>(null)
export const ComposerSurfaceProvider = ComposerSurfaceContext.Provider
export const useComposerSurfaceId = (): string | null => useContext(ComposerSurfaceContext)
@@ -0,0 +1,186 @@
import type { Unstable_TriggerAdapter, Unstable_TriggerItem } from '@assistant-ui/core'
import { act, fireEvent, render } from '@testing-library/react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { describe, expect, it, vi } from 'vitest'
import { useLiveCompletionAdapter } from './hooks/use-live-completion-adapter'
import { detectTrigger, type TriggerState } from './text-utils'
// Faithful mirror of index.tsx's trigger wiring, driven through REAL DOM
// keydown+keyup events on a contentEditable. Exercises the parts a direct
// reducer-call repro misses: the keyup -> refreshTrigger path, the
// keydown-set "consumed" ref that guards it, and per-press keydown+keyup
// ordering (critical for Escape, whose keydown nulls `trigger` before keyup).
function Harness({
onState
}: {
onState: (s: { active: number; items: readonly Unstable_TriggerItem[]; open: boolean }) => void
}) {
const editorRef = useRef<HTMLDivElement>(null)
const triggerKeyConsumedRef = useRef(false)
const [trigger, setTrigger] = useState<TriggerState | null>(null)
const [triggerActive, setTriggerActive] = useState(0)
const [triggerItems, setTriggerItems] = useState<readonly Unstable_TriggerItem[]>([])
const { adapter } = useLiveCompletionAdapter({
enabled: true,
debounceMs: 0,
fetcher: async (query: string) => ({
query,
items: Array.from({ length: 5 }, (_, i) => ({ text: `/cmd${i}`, display: `/cmd${i}`, meta: '' }))
}),
toItem: (entry, index) => ({ id: `${entry.text}|${index}`, type: 'slash', label: entry.text.slice(1) })
})
const triggerAdapter: Unstable_TriggerAdapter | null = trigger?.kind === '/' ? adapter : null
const refreshTrigger = useCallback(() => {
const editor = editorRef.current
if (!editor) {
return
}
const raw = editor.textContent ?? ''
if (!raw.includes('@') && !raw.includes('/')) {
if (trigger) {
setTrigger(null)
setTriggerActive(0)
}
return
}
const detected = detectTrigger(raw)
setTrigger(detected)
if (detected?.kind !== trigger?.kind || detected?.query !== trigger?.query) {
setTriggerActive(0)
}
}, [trigger])
useEffect(() => {
if (!trigger || !triggerAdapter?.search) {
setTriggerItems([])
return
}
setTriggerItems(triggerAdapter.search(trigger.query))
}, [trigger, triggerAdapter])
useEffect(() => {
setTriggerActive(idx => Math.min(idx, Math.max(0, triggerItems.length - 1)))
}, [triggerItems.length])
onState({ active: triggerActive, items: triggerItems, open: trigger !== null })
const closeTrigger = () => {
setTrigger(null)
setTriggerItems([])
setTriggerActive(0)
}
// Exact copies of index.tsx handlers, including the keydown-set "consumed"
// ref that the keyup consults.
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (trigger && triggerItems.length > 0) {
if (event.key === 'ArrowDown') {
event.preventDefault()
triggerKeyConsumedRef.current = true
setTriggerActive(idx => (idx + 1) % triggerItems.length)
return
}
if (event.key === 'ArrowUp') {
event.preventDefault()
triggerKeyConsumedRef.current = true
setTriggerActive(idx => (idx - 1 + triggerItems.length) % triggerItems.length)
return
}
if (event.key === 'Escape') {
event.preventDefault()
triggerKeyConsumedRef.current = true
closeTrigger()
return
}
}
}
const handleKeyUp = () => {
if (triggerKeyConsumedRef.current) {
triggerKeyConsumedRef.current = false
return
}
// index.tsx defers via setTimeout(refreshTrigger, 0); call synchronously
// here so the test deterministically observes the keyup-driven refresh.
refreshTrigger()
}
return (
<div
contentEditable
data-testid="editor"
onInput={() => refreshTrigger()}
onKeyDown={handleKeyDown}
onKeyUp={handleKeyUp}
ref={editorRef}
suppressContentEditableWarning
/>
)
}
async function flush() {
await act(async () => {
await new Promise(r => setTimeout(r, 20))
})
}
describe('slash menu navigation — real DOM keydown+keyup', () => {
it('cycles through ALL items and Esc closes (and stays closed)', async () => {
vi.useRealTimers()
let latest = { active: 0, items: [] as readonly Unstable_TriggerItem[], open: false }
const { getByTestId } = render(<Harness onState={s => (latest = s)} />)
const editor = getByTestId('editor')
// Simulate typing '/'.
await act(async () => {
editor.textContent = '/'
fireEvent.input(editor)
})
await flush()
expect(latest.open).toBe(true)
expect(latest.items.length).toBe(5)
// ArrowDown 6x with REAL keydown+keyup pairs. Bug = stuck [0,1,0,1,...].
const seen: number[] = [latest.active]
for (let i = 0; i < 6; i++) {
await act(async () => {
fireEvent.keyDown(editor, { key: 'ArrowDown' })
fireEvent.keyUp(editor, { key: 'ArrowDown' })
await Promise.resolve()
})
seen.push(latest.active)
}
expect(seen).toEqual([0, 1, 2, 3, 4, 0, 1])
// Escape: keydown closes; keyup must NOT reopen (the '/' is still in text).
await act(async () => {
fireEvent.keyDown(editor, { key: 'Escape' })
fireEvent.keyUp(editor, { key: 'Escape' })
await Promise.resolve()
})
await flush()
expect(latest.open).toBe(false)
})
})
@@ -0,0 +1,57 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { rememberDesktopCommandsCatalog } from '@/lib/desktop-slash-commands'
import { slashCommandMatches } from './slash-refs'
const commands = (text: string, options?: Parameters<typeof slashCommandMatches>[1]) =>
slashCommandMatches(text, options).map(match => `${match.kind}:${match.command}`)
describe('slashCommandMatches', () => {
beforeEach(() => {
rememberDesktopCommandsCatalog({
commands: { '/goal': { argument_mode: 'mixed', desktop: null } }
})
})
afterEach(() => {
rememberDesktopCommandsCatalog(undefined)
})
it('recognizes a leading command and a skill named mid-prose', () => {
expect(commands('/some-skill clean this with /other-skill please')).toEqual([
'skill:/some-skill',
'skill:/other-skill'
])
})
it('leaves a path alone — /usr/local/bin is not a command', () => {
expect(commands('see /usr/local/bin ')).toEqual([])
})
it('holds a trailing token as still-typed unless the text is inert', () => {
expect(commands('/some-skill')).toEqual([])
expect(commands('/some-skill', { trailingCommitted: true })).toEqual(['skill:/some-skill'])
})
it('leaves an arg-taking command as text — its tail may be prose', () => {
expect(commands('/goal ship the redesign')).toEqual([])
})
it('leaves a command with no desktop surface as text', () => {
expect(commands('/exit now')).toEqual([])
})
it('offers a built-in only as an invocation, never mid-message', () => {
// Mirrors what the popover offers: `/new` acts on the app, so it means
// nothing dropped into a sentence, while a skill reads as "handle this
// part with X".
expect(commands('/new ')).toEqual(['command:/new'])
expect(commands('start over with /new ')).toEqual([])
expect(commands('start over with /some-skill ')).toEqual(['skill:/some-skill'])
})
it('disqualifies a leading token when the text lands mid-word', () => {
expect(commands('/some-skill ', { boundaryBefore: false })).toEqual([])
})
})
@@ -0,0 +1,105 @@
/**
* Slash-command recognition for text the composer did not watch being typed —
* a paste, a restored draft, an undo step, a rebuilt line.
*
* The typed path chips a command as it's picked or accepted, so the composer
* agrees with what the sent message renders (`SLASH_SKILL_RE` in
* directive-text). Text that arrives whole never passed through that path, so
* it needs the same commands recognized in place — on exactly the terms the
* typed path would have used, or hydration invents pills the popover would
* never have committed.
*/
import type { SlashChipKind } from '@/components/assistant-ui/directive-text'
import {
desktopSlashCommandArgumentMode,
isDesktopSlashCommand,
resolveDesktopCommand
} from '@/lib/desktop-slash-commands'
// A command token starts a word and doesn't continue into a path: `/usr/local`
// is a path, not a `/usr` command. Same shape the sent message uses to decide
// what renders as a pill, so the composer and the transcript agree.
const SLASH_COMMAND_RE = /(?<=^|\s)\/([a-zA-Z][\w-]*)(?![\w-]*\/)/g
export interface SlashCommandMatch {
/** The command with its leading slash, e.g. `/clean`. */
command: string
end: number
kind: SlashChipKind
start: number
}
export interface SlashCommandScanOptions {
/**
* Whether the text is preceded by a token boundary. False when it's being
* inserted mid-word (a paste landing against existing characters), which
* disqualifies a token at index 0 — `foo/clean` is not a command. It also
* makes that token mid-message rather than an invocation.
*/
boundaryBefore?: boolean
/**
* Whether a token ending the text counts as committed. True for inert text
* (a paste, dropped content): nothing is being typed, so `/clean` at the end
* is the whole command. False while editing live, where a trailing `/wor` is
* a half-typed query the popover owns and must leave editable.
*/
trailingCommitted?: boolean
}
/**
* Only commands with NO argument stage chip: their committed pill is exactly
* the bare `/name`, so the boundary is unambiguous. Arg-taking commands
* (`/goal ship it`) stay text — their tail may be prose. Commands with no
* desktop surface at all (`/exit`, `/config`) stay text too.
*/
function chippableKind(command: string): SlashChipKind | null {
if (!isDesktopSlashCommand(command) || desktopSlashCommandArgumentMode(command) !== null) {
return null
}
return resolveDesktopCommand(command) ? 'command' : 'skill'
}
/** Every `/command` in `text` that should render as a pill, in source order. */
export function slashCommandMatches(text: string, options: SlashCommandScanOptions = {}): SlashCommandMatch[] {
const { boundaryBefore = true, trailingCommitted = false } = options
if (!text.includes('/')) {
return []
}
const matches: SlashCommandMatch[] = []
for (const match of text.matchAll(SLASH_COMMAND_RE)) {
const start = match.index ?? 0
const command = match[0]
const end = start + command.length
const after = text[end]
// A committed pill always carries its auto-inserted trailing space, which
// is what separates it from a token still being typed.
if (after === undefined ? !trailingCommitted : !/\s/.test(after)) {
continue
}
// Only the FIRST token can be an invocation, and only when the text lands
// on a token boundary — `foo` + a pasted `/clean` is `foo/clean`.
const invocation = start === 0
if (invocation && !boundaryBefore) {
continue
}
const kind = chippableKind(command)
// Later tokens are references dropped into prose, where the popover offers
// SKILLS alone — a built-in like `/new` acts on the app and means nothing
// mid-sentence. Hydration has to agree, or pasted text grows pills typing
// never would.
if (kind && (invocation || kind === 'skill')) {
matches.push({ command, end, kind, start })
}
}
return matches
}
@@ -0,0 +1,93 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { atom } from 'nanostores'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { $notifications, clearNotifications } from '@/store/notifications'
vi.mock('@/store/coding-status', () => ({
registerRepoStatusCwd: () => undefined,
repoStatusForCwd: () =>
atom({
added: 12,
ahead: 0,
behind: 0,
branch: 'bb/hitbox',
defaultBranch: 'main',
detached: false,
removed: 3,
untracked: 0
}),
repoWorktreesForCwd: () => atom([])
}))
const { CodingStatusRow } = await import('./coding-row')
describe('CodingStatusRow', () => {
afterEach(() => {
cleanup()
})
it('opens the review pane from the branch and the diff counts, never the bar itself', () => {
const onOpen = vi.fn()
const { container } = render(<CodingStatusRow onOpen={onOpen} repoPath="/repo" />)
const bar = container.querySelector<HTMLElement>('.coding-status-bar')
expect(bar).not.toBeNull()
fireEvent.click(bar!)
expect(onOpen).not.toHaveBeenCalled()
fireEvent.click(screen.getByText('bb/hitbox'))
expect(onOpen).toHaveBeenCalledTimes(1)
fireEvent.click(screen.getByText('12'))
expect(onOpen).toHaveBeenCalledTimes(2)
})
it('wraps the click targets without adding a layout box', () => {
const { container } = render(<CodingStatusRow onOpen={() => undefined} repoPath="/repo" />)
// `display: contents` is what keeps the branch label and the counts direct
// flex children of the row — the hit areas cost nothing visually.
expect(screen.getByText('bb/hitbox').parentElement?.classList.contains('contents')).toBe(true)
expect(screen.getByText('12').closest('button')?.classList.contains('contents')).toBe(true)
// The glyph button fills the row's existing 3.5 leading slot exactly.
expect(container.querySelector('button[class~="size-3.5"]')).not.toBeNull()
})
it('parks the copy glyph against the end of the path, not the end of the row', () => {
render(<CodingStatusRow onOpen={() => undefined} repoPath="/Users/someone/www/repo" />)
const path = screen.getByText('~/www/repo')
// The path sizes to its content and the glyph is its immediate sibling, so
// the pair reads as one unit. `flex-1` belongs to the wrapper (which holds
// the row's slack open) — on the label it stretched the text and pushed the
// glyph out to the kebab.
expect(path.classList.contains('flex-1')).toBe(false)
expect(path.parentElement?.classList.contains('flex-1')).toBe(true)
expect(path.nextElementSibling?.tagName).toBe('BUTTON')
})
it('copies the absolute cwd inline — checkmark feedback, no toast', async () => {
const writeText = vi.fn().mockResolvedValue(undefined)
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
clearNotifications()
render(<CodingStatusRow onOpen={() => undefined} repoPath="/Users/someone/www/repo" />)
// Painted tildified, copied raw.
expect(screen.getByText('~/www/repo')).toBeTruthy()
const copy = screen.getByRole('button', { name: 'Copy path' })
fireEvent.click(copy)
await waitFor(() => expect(writeText).toHaveBeenCalledWith('/Users/someone/www/repo'))
// Confirmation is the button turning into a checkmark, not a notification.
await waitFor(() => expect(screen.getByRole('button', { name: 'Copied' })).toBeTruthy())
expect($notifications.get()).toHaveLength(0)
})
})
@@ -0,0 +1,332 @@
import { useStore } from '@nanostores/react'
import { memo, useEffect } from 'react'
import { PrTag } from '@/app/chat/pr-tag'
import { StatusRow } from '@/components/chat/status-row'
import {
type ActionItemSpec,
ActionsContextMenu,
ActionsMenu,
type MenuKit,
renderActionItem
} from '@/components/ui/actions-menu'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { CopyButton } from '@/components/ui/copy-button'
import { DiffCount } from '@/components/ui/diff-count'
import type { HermesGitBranch } from '@/global'
import { useI18n } from '@/i18n'
import { displayPath } from '@/lib/display-path'
import { openWorktreeDialog, registerRepoStatusCwd, repoStatusForCwd, repoWorktreesForCwd } from '@/store/coding-status'
import { notifyError } from '@/store/notifications'
import { $pullRequestsByBranch, branchPrKey, refreshPullRequests } from '@/store/pull-requests'
// Tiny uppercase section header, matching the composer "+" menu's labels.
const MENU_SECTION = 'text-[0.625rem] font-semibold uppercase tracking-wider text-(--ui-text-tertiary)'
interface CodingStatusRowProps {
/** Branch the current draft off into a fresh worktree + session, based on
* `base` (a branch name; omitted = current HEAD). The composer owns the
* draft, so it supplies the orchestration; the row just collects the new
* branch name + base. Omitted (e.g. remote backend) hides the affordance. */
onBranchOff?: (branch: string, base?: string) => Promise<void>
/** Check an existing branch out into a fresh worktree + session (no new
* branch). Drives the dialog's "convert a branch" picker. */
onConvertBranch?: (branch: string, path?: null | string, isDefault?: boolean) => Promise<void>
/** List the repo's local branches for the "convert a branch" picker. */
onListBranches?: () => Promise<HermesGitBranch[]>
/** Open the review pane (changed files + diffs). */
onOpen?: () => void
/** Jump into an existing worktree (open a fresh session anchored there). */
onOpenWorktree?: (path: string) => void
/** Switch the current repo checkout to another branch. */
onSwitchBranch?: (branch: string) => Promise<void>
/** Repo root path for the worktree dialog. */
repoPath?: null | string
}
/**
* The always-on coding-context row, the BASE of the composer status stack:
* current branch, dirty summary (+/-), and ahead/behind. A touch more prominent
* than the per-turn rows above it (larger branch label, accent glyph), and the
* entry point to the review pane. Hidden when the active session isn't in a
* local git repo (the probe returns null).
*/
export const CodingStatusRow = memo(function CodingStatusRow({
onBranchOff,
onConvertBranch,
onListBranches,
onOpen,
onOpenWorktree,
onSwitchBranch,
repoPath
}: CodingStatusRowProps) {
const { t } = useI18n()
const s = t.statusStack.coding
const p = t.sidebar.projects
const fileMenu = t.fileMenu
const resolvedRepoPath = repoPath?.trim() || undefined
// This surface's OWN worktree, always — never the primary's. The row used to
// fall back to the global `$repoStatus` for a blank repoPath, which painted
// the main pane's branch/± onto a tile whose cwd hadn't resolved yet. That
// fallback bought nothing (the primary's computed is keyed to `$currentCwd`,
// which is blank in exactly the same case) and cost a wrong-tree rail.
const status = useStore(repoStatusForCwd(resolvedRepoPath))
const worktrees = useStore(repoWorktreesForCwd(resolvedRepoPath))
// While mounted, keep this worktree in the coding-status refresh set so the
// turn-settle / tool-complete / focus edges re-probe it too (tiles otherwise
// only refreshed when the MAIN cwd probe happened to cover them).
useEffect(() => registerRepoStatusCwd(resolvedRepoPath), [resolvedRepoPath])
// The branch's PR, so the rail links to it instead of leaving you to go find
// it. One `gh` lookup for this one branch, TTL-cached in the store and shared
// with the sidebar's badges.
const prBranch = status?.detached ? null : status?.branch || null
useEffect(() => {
if (resolvedRepoPath && prBranch) {
void refreshPullRequests({ [resolvedRepoPath]: [prBranch] })
}
}, [resolvedRepoPath, prBranch])
const pr =
useStore($pullRequestsByBranch)[resolvedRepoPath && prBranch ? branchPrKey(resolvedRepoPath, prBranch) : '']
const switchToBranch = async (branch: string) => {
if (!onSwitchBranch) {
return
}
try {
await onSwitchBranch(branch)
} catch (err) {
notifyError(err, s.switchFailed(branch))
}
}
// useKeybinds now handles the ⌘⇧B hotkey globally, through
// openWorktreeDialog. One dialog is mounted in the sidebar, so N mounted
// rails can no longer each open their own copy. The menu items below only
// publish the intent. They pin the repo of THIS rail, so the kebab of a tile
// targets the worktree of that tile.
const startBranch = (base: string | undefined) => {
void openWorktreeDialog({ base, repoPath: resolvedRepoPath })
}
if (!status) {
return null
}
const branchLabel = status.detached ? s.detached : status.branch || s.noBranch
// The kebab offers branching off the trunk and/or the current branch. The
// worktree-add bases the new branch on `base` (a branch name; undefined =
// current HEAD). We dedupe so "on main" shows a single trunk entry, and fall
// back to a plain off-HEAD branch when no trunk is detected.
const current = status.detached ? null : status.branch
const branchTargets: { base: string | undefined; label: string }[] = []
// Current branch first (the 99% "branch off where I am"), then the trunk just
// below it ("New branch from main"), deduped when they're the same.
if (current) {
branchTargets.push({ base: current, label: s.branchOffFrom(current) })
}
if (status.defaultBranch && status.defaultBranch !== current) {
branchTargets.push({ base: status.defaultBranch, label: s.branchOffFrom(status.defaultBranch) })
}
if (branchTargets.length === 0) {
branchTargets.push({ base: undefined, label: s.newBranch })
}
const switchTarget =
onSwitchBranch && current && status.defaultBranch && status.defaultBranch !== current ? status.defaultBranch : null
// Other worktrees to jump into — everything except the one we're already in
// (matched by its checked-out branch) and the bare/main placeholder entry.
const otherWorktrees = onOpenWorktree
? worktrees.filter(w => w.path && !w.detached && w.branch && w.branch !== current)
: []
const hasLineDelta = status.added > 0 || status.removed > 0
// Untracked files carry no line delta vs HEAD, so surface them as a count when
// they're the only change (otherwise +/- tells the story).
const untrackedOnly = !hasLineDelta && status.untracked > 0
// The branch actions, rendered identically by the kebab dropdown and the
// row's right-click menu so the two never drift. `onBranchOff` gates the
// whole menu (omitted = remote backend), matching the kebab.
const renderBranchItems = (kit: MenuKit) => {
const branchItems: ActionItemSpec[] = branchTargets.map(target => ({
key: target.base ?? '__head__',
label: <span className="truncate">{target.label}</span>,
onSelect: () => startBranch(target.base)
}))
const worktreeItems: ActionItemSpec[] = otherWorktrees.map(worktree => ({
key: worktree.path,
label: <span className="truncate">{worktree.branch}</span>,
onSelect: () => onOpenWorktree?.(worktree.path)
}))
return (
<>
<kit.Label className={MENU_SECTION}>{s.newBranch}</kit.Label>
{branchItems.map(item => renderActionItem(kit, item))}
{switchTarget &&
renderActionItem(kit, {
key: '__switch__',
label: <span className="truncate">{s.switchTo(switchTarget)}</span>,
onSelect: () => void switchToBranch(switchTarget)
})}
<kit.Separator />
<kit.Label className={MENU_SECTION}>{s.worktrees}</kit.Label>
{worktreeItems.map(item => renderActionItem(kit, item))}
{/* Create a fresh worktree off the current HEAD (the generic "spin up a
worktree here", mirroring the sidebar's + button). */}
{renderActionItem(kit, {
key: '__start__',
label: <span className="truncate">{p.startWork}</span>,
onSelect: () => startBranch(undefined)
})}
{onConvertBranch &&
renderActionItem(kit, {
key: '__convert__',
label: <span className="truncate">{p.convertBranch}</span>,
onSelect: () => startBranch(undefined)
})}
</>
)
}
return (
<>
<ActionsContextMenu contentClassName="w-60" disabled={!onBranchOff} items={renderBranchItems}>
<StatusRow
// The base "where am I working" strip is part of the composer surface
// itself, so it inherits the composer's width and clipped top radius.
className="coding-status-bar min-h-7 rounded-t-[inherit] rounded-b-none border-b border-(--ui-stroke-tertiary) px-3.5 py-1.5 hover:bg-transparent"
// Static branch glyph — never the loading spinner. This row only renders
// once `status` exists, so a spinner here only ever fired on *refreshes*
// of an already-loaded repo (window focus, turn settle), reading as an
// annoying icon "blip" with no first-load value. Refreshes are silent.
// It's a button (not the whole row) so the glyph opens the review pane
// while the strip around it stays inert; size-3.5 fills the slot exactly.
leading={
<button className="flex size-3.5 items-center justify-center" onClick={onOpen} type="button">
<Codicon className="text-(--ui-green)" name="git-branch" size="0.8rem" />
</button>
}
>
<div className="flex min-w-0 flex-1 items-center gap-1">
{/* PR number first, right against the leading git glyph — the chip
borrows that icon instead of carrying a second one of its own
(`showIcon={false}`), so the row reads glyph → #number → branch. */}
{pr && <PrTag pr={pr} showIcon={false} />}
{/* Branch name — the other half of the review-pane target. `contents`
so the button lays out nothing of its own: the label stays the
same flex child it always was, and the hit area is the text. */}
<button className="contents" onClick={onOpen} type="button">
<span className="min-w-0 truncate text-xs font-normal text-muted-foreground/92" title={branchLabel}>
{branchLabel}
</span>
</button>
{/* Worktree path + copy — plain muted text, not a chip. Always in the
flex so hover doesn't reflow the row; opacity alone reveals the
pair. The path sizes to its content (the `flex-1` lives on the
wrapper) so the glyph sits against the end of the text instead of
drifting to the far edge of the row. `displayPath` collapses
home → ~; the copy still takes the real absolute path, and it's
the shared `CopyButton` so it confirms with the same inline
checkmark as every other copy in the app. */}
{resolvedRepoPath && (
<div className="flex min-w-0 flex-1 items-center gap-0.5 opacity-0 transition-opacity group-hover/status-row:opacity-100 group-focus-within/status-row:opacity-100">
<span
className="min-w-0 truncate font-mono text-[0.62rem] leading-4 text-muted-foreground/50"
data-slot="coding-status-cwd"
>
{displayPath(resolvedRepoPath)}
</span>
<CopyButton
appearance="icon"
buttonSize="icon-xs"
className="pointer-events-none size-4 shrink-0 text-muted-foreground/50 hover:text-foreground group-hover/status-row:pointer-events-auto group-focus-within/status-row:pointer-events-auto"
iconClassName="size-3"
label={fileMenu.copyPath}
side="top"
stopPropagation
text={resolvedRepoPath}
/>
</div>
)}
{/* Branch actions kebab — same pattern as the session/worktree rows.
ALWAYS laid out; only its opacity flips on hover/focus/open, so
revealing it never reflows the row (no layout shift). pointer-events
follow opacity so the invisible trigger isn't clickable at rest. */}
{onBranchOff && (
<ActionsMenu
align="end"
contentClassName="w-60"
// The row sits at the bottom of the screen (above the composer),
// so the menu opens upward.
items={renderBranchItems}
side="top"
>
<Button
aria-label={s.newBranch}
className="pointer-events-none size-4 shrink-0 text-muted-foreground/60 opacity-0 transition hover:text-foreground group-hover/status-row:pointer-events-auto group-hover/status-row:opacity-100 group-focus-within/status-row:pointer-events-auto group-focus-within/status-row:opacity-100 data-[state=open]:pointer-events-auto data-[state=open]:opacity-100"
size="icon-xs"
variant="ghost"
>
<Codicon name="kebab-vertical" size="0.8rem" />
</Button>
</ActionsMenu>
)}
</div>
{/* The counts describe what's in the review pane, so clicking them
opens it. `contents` again: the two spans stay direct flex children
of the row, keeping their gap and `ml-auto` behaviour untouched. */}
{(status.ahead > 0 || status.behind > 0 || hasLineDelta || untrackedOnly) && (
<button className="contents" onClick={onOpen} type="button">
{(status.ahead > 0 || status.behind > 0) && (
<span className="ml-auto flex shrink-0 items-center gap-1.5 text-[0.68rem] leading-4 text-muted-foreground/75 tabular-nums">
{status.ahead > 0 && (
<span className="flex items-center gap-0.5" title={s.ahead(status.ahead)}>
<span aria-hidden>↑</span>
{status.ahead}
</span>
)}
{status.behind > 0 && (
<span className="flex items-center gap-0.5" title={s.behind(status.behind)}>
<span aria-hidden>↓</span>
{status.behind}
</span>
)}
</span>
)}
{hasLineDelta ? (
<DiffCount
added={status.added}
className={`text-[0.72rem] leading-4 ${status.ahead === 0 && status.behind === 0 ? 'ml-auto' : ''}`}
removed={status.removed}
/>
) : untrackedOnly ? (
<span
className={`shrink-0 text-[0.72rem] leading-4 text-amber-500/90 ${status.ahead === 0 && status.behind === 0 ? 'ml-auto' : ''}`}
>
{s.changed(status.untracked)}
</span>
) : null}
</button>
)}
</StatusRow>
</ActionsContextMenu>
</>
)
})
@@ -0,0 +1,79 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { MemoryRouter } from 'react-router'
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
import { $todosBySession } from '@/store/todos'
import { ComposerStatusStack } from './index'
describe('ComposerStatusStack collapsed todo indicator', () => {
beforeAll(() => {
vi.stubGlobal(
'ResizeObserver',
class {
disconnect() {}
observe() {}
}
)
})
afterEach(() => {
cleanup()
$todosBySession.set({})
})
it('shows a running indicator while the todo group is expanded', () => {
$todosBySession.set({
'session-1': [{ content: 'Wire the status stack', id: '1', status: 'in_progress' }]
})
render(
<MemoryRouter>
<ComposerStatusStack queue={null} sessionId="session-1" />
</MemoryRouter>
)
expect(screen.getByText('Wire the status stack')).toBeTruthy()
expect(screen.getAllByRole('status').length).toBeGreaterThan(0)
expect(screen.getByText('Tasks 0/1')).toBeTruthy()
})
it('shows a running indicator next to the collapsed todo label', () => {
$todosBySession.set({
'session-1': [{ content: 'Wire the status stack', id: '1', status: 'in_progress' }]
})
render(
<MemoryRouter>
<ComposerStatusStack queue={null} sessionId="session-1" />
</MemoryRouter>
)
const button = screen.getByRole('button', { name: /Tasks 0\/1/ })
fireEvent.click(button)
const label = screen.getByText('Tasks 0/1')
const indicator = screen.getByRole('status')
expect(screen.queryByText('Wire the status stack')).toBeNull()
expect(button.contains(indicator)).toBe(true)
expect(label.compareDocumentPosition(indicator) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
})
it('does not show a collapsed todo indicator when no todo is running', () => {
$todosBySession.set({
'session-1': [{ content: 'Wire the status stack', id: '1', status: 'completed' }]
})
render(
<MemoryRouter>
<ComposerStatusStack queue={null} sessionId="session-1" />
</MemoryRouter>
)
fireEvent.click(screen.getByRole('button', { name: /Tasks 1\/1/ }))
expect(screen.queryByText('Wire the status stack')).toBeNull()
expect(screen.queryByRole('status')).toBeNull()
})
})
@@ -0,0 +1,87 @@
import { cleanup, render, screen } from '@testing-library/react'
import { MemoryRouter } from 'react-router'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { I18nProvider } from '@/i18n'
import { $goalsBySession, type SessionGoal } from '@/store/goals'
import { ComposerStatusStack } from './index'
// The stack measures itself into a surface var — jsdom has no ResizeObserver.
class ResizeObserverStub {
observe() {}
unobserve() {}
disconnect() {}
}
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
const SID = 'sess-goal-1'
const goal = (status: SessionGoal['status'], title = 'ship the feature', detail?: string): SessionGoal => ({
detail,
status,
title,
updatedAt: Date.now()
})
function renderStack(sessionId: null | string = SID) {
return render(
<MemoryRouter>
<I18nProvider configClient={null} initialLocale="en">
<ComposerStatusStack queue={null} sessionId={sessionId} />
</I18nProvider>
</MemoryRouter>
)
}
describe('ComposerStatusStack goal indicator', () => {
beforeEach(() => {
$goalsBySession.set({})
})
afterEach(() => {
cleanup()
$goalsBySession.set({})
})
it('renders nothing when the session has no goal', () => {
const view = renderStack()
expect(view.container.firstChild).toBeNull()
})
it('shows an active goal with its title', () => {
$goalsBySession.set({ [SID]: goal('active') })
renderStack()
expect(screen.getByText('Goal active')).toBeTruthy()
expect(screen.getByText('ship the feature')).toBeTruthy()
})
it('labels a paused goal as paused', () => {
$goalsBySession.set({ [SID]: goal('paused') })
renderStack()
expect(screen.getByText('Goal paused')).toBeTruthy()
expect(screen.getByText('ship the feature')).toBeTruthy()
})
it('shows the continuation detail line for an active goal', () => {
$goalsBySession.set({ [SID]: goal('active', 'ship it', 'Continuing toward goal (3/20)') })
renderStack()
expect(screen.getByText('Continuing toward goal (3/20)')).toBeTruthy()
})
it('scopes the indicator to the goal-owning session', () => {
$goalsBySession.set({ 'other-session': goal('active') })
const view = renderStack()
expect(view.container.firstChild).toBeNull()
})
})
@@ -0,0 +1,276 @@
import { useStore } from '@nanostores/react'
import { type ReactNode, useEffect, useMemo } from 'react'
import { useNavigate } from 'react-router'
import { blurComposerInput } from '@/app/chat/composer/focus'
import { AGENTS_ROUTE } from '@/app/routes'
import { BillingBanner } from '@/components/billing-banner'
import { composerDockCard } from '@/components/chat/composer-dock'
import { StatusSection } from '@/components/chat/status-section'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { GlyphSpinner } from '@/components/ui/glyph-spinner'
import { Tip, TipKeybindLabel } from '@/components/ui/tooltip'
import { type Translations, useI18n } from '@/i18n'
import { useSessionSlice } from '@/lib/use-session-slice'
import { cn } from '@/lib/utils'
import { $billingBlock } from '@/store/billing-block'
import {
$statusItemsBySession,
type ComposerStatusItem,
dismissBackgroundProcess,
groupStatusItems,
refreshBackgroundProcesses,
type StatusGroup,
stopBackgroundProcess
} from '@/store/composer-status'
import { refreshSessionGoal } from '@/store/goals'
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
// emit no event when they die). Only armed while a running row is on screen.
const BACKGROUND_POLL_MS = 5_000
// A localhost/loopback preview is only meaningful while its dev server is up, so
// we tie it to a live background process rather than persisting dismissals or
// letting dead URLs pile up. File previews (a real on-disk artifact) stand alone.
const isLocalhostPreview = (target: string): boolean => /\b(?:localhost|127\.0\.0\.1|0\.0\.0\.0)\b/i.test(target)
// Real codicons per group (no sparkles): a checklist for todos, the agent glyph
// for subagents, a background process glyph for background tasks.
const GROUP_ICON: Record<StatusGroup['type'], string> = {
goal: 'target',
todo: 'checklist',
subagent: 'agent',
background: 'server-process'
}
const groupLabel = (group: StatusGroup, s: Translations['statusStack']) => {
if (group.type === 'goal') {
const status = group.items[0]?.goalStatus
return status === 'paused'
? s.goalPaused
: status === 'waiting'
? s.goalWaiting
: status === 'done'
? s.goalDone
: s.goalActive
}
if (group.type === 'todo') {
return s.todos(group.items.filter(i => i.todoStatus === 'completed').length, group.items.length)
}
return group.type === 'subagent' ? s.subagents(group.items.length) : s.background(group.items.length)
}
const hasRunningTodo = (group: StatusGroup) =>
group.type === 'todo' && group.items.some(item => item.todoStatus === 'in_progress' && item.state === 'running')
interface ComposerStatusStackProps {
/** The queue, built by the composer (it owns the queue's callbacks). Rendered
* as the last group so it stays fused to the composer like before. */
queue: ReactNode
sessionId: null | string
}
/**
* The status "sink" above the composer: one card (the queue's chrome) holding
* every session-scoped status — subagents, background tasks, queue — grouped by
* type and separated by light dividers. Collapses to nothing when empty.
*/
export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackProps) {
const { t } = useI18n()
const navigate = useNavigate()
// Subscribe to THIS session's slice only. Both maps churn on other
// sessions' activity (subagent ticks, background polls, preview updates in
// any tile); a whole-map `useStore` re-rendered every mounted stack — one
// per open tile — on all of it. The per-key arrays are referentially stable
// across unrelated writes, so the slice hook bails out unless OUR session's
// items actually changed.
const items = useSessionSlice($statusItemsBySession, sessionId)
const previews = useSessionSlice($previewStatusBySession, sessionId)
const scrolledUp = useStore($threadScrolledUp)
const billing = useStore($billingBlock)
const groups = useMemo(() => groupStatusItems(items), [items])
// Seed from the registry on session open; event-driven refreshes (terminal /
// process tool completions) live in use-message-stream. This must NOT reset
// the gone-polling latch: a mount/remount is not proof of a fresh runtime
// binding (a boot-restored tile can remount repeatedly while still bound to
// a dead runtime id), so clearing it here re-arms an endless 4001 storm
// against that id. The latch is reset at the actual rebind seams instead —
// gateway reconnect and runtime re-mint (see resetBackgroundPollingGuard
// call sites in use-gateway-boot.ts and store/gateway.ts).
useEffect(() => {
if (sessionId) {
void refreshBackgroundProcesses(sessionId)
void refreshSessionGoal(sessionId)
}
}, [sessionId])
const hasRunningBackground = groups.some(g => g.type === 'background' && g.items.some(i => i.state === 'running'))
// Drop localhost previews once no dev server is left running — that's what made
// dead `localhost:5174` chips stick around. On-disk file previews are kept.
const visiblePreviews = previews.filter(item => hasRunningBackground || !isLocalhostPreview(item.target))
useEffect(() => {
if (!sessionId || !hasRunningBackground) {
return
}
const timer = setInterval(() => void refreshBackgroundProcesses(sessionId), BACKGROUND_POLL_MS)
return () => clearInterval(timer)
}, [hasRunningBackground, sessionId])
const openAgents = () => navigate(AGENTS_ROUTE)
const openSubagent = (item: ComposerStatusItem) =>
item.sessionId ? void openSessionInNewWindow(item.sessionId, { watch: true }) : openAgents()
// Preview links live as child rows of the background group — a localhost dev
// server and its preview are the same thing — so they no longer float as an
// odd, differently-indented standalone block under the stack.
const previewRows =
visiblePreviews.length > 0 && sessionId
? visiblePreviews.map(item => (
<PreviewStatusRow item={item} key={item.id} onDismiss={id => dismissPreviewArtifact(sessionId, id)} />
))
: []
const hasBackgroundGroup = groups.some(g => g.type === 'background')
const previewBlock = <div className="px-1 py-0.5">{previewRows}</div>
const sections: { key: string; node: ReactNode }[] = []
// Billing wall sits at the very top of the stack — it's the most important
// thing above the composer when the account is out of credits. Rendered here
// (not as a composer-disable) so slash commands stay usable.
if (billing && sessionId && billing.sessionId === sessionId) {
sections.push({ key: 'billing', node: <BillingBanner sessionId={sessionId} /> })
}
for (const group of groups) {
sections.push({
key: group.type,
node: (
<StatusSection
accessory={
group.type === 'subagent' ? (
<Tip label={<TipKeybindLabel actionId="nav.agents" text={t.statusStack.agents} />}>
<Button
className="text-muted-foreground/75 hover:text-foreground/90"
onClick={openAgents}
size="micro"
type="button"
variant="text"
>
{t.statusStack.agents}
</Button>
</Tip>
) : undefined
}
collapsedIndicator={
hasRunningTodo(group) ? (
<GlyphSpinner
ariaLabel={t.statusStack.running}
className="text-[0.8rem] leading-none text-muted-foreground/80"
spinner="braille"
/>
) : undefined
}
defaultCollapsed={group.type !== 'todo' && group.type !== 'goal'}
icon={<Codicon className="text-muted-foreground/70" name={GROUP_ICON[group.type]} size="0.8rem" />}
label={groupLabel(group, t.statusStack)}
>
{group.items.map(item => (
<StatusItemRow
item={item}
key={item.id}
onDismiss={sessionId ? id => dismissBackgroundProcess(sessionId, id) : undefined}
onOpen={() => openSubagent(item)}
onStop={sessionId ? id => void stopBackgroundProcess(sessionId, id) : undefined}
/>
))}
</StatusSection>
)
})
// Preview links belong to the background group (a localhost dev server and
// its preview are the same thing), but they must stay VISIBLE even when that
// group is collapsed — the whole point is a one-tap open. Render them as an
// always-visible block right after the background section, not as collapsible
// children that get swallowed the moment a background task appears.
if (group.type === 'background' && previewRows.length > 0) {
sections.push({ key: 'preview', node: previewBlock })
}
}
// No background group to host them (e.g. a standalone on-disk file preview):
// still render them as their own always-visible block.
if (previewRows.length > 0 && !hasBackgroundGroup) {
sections.push({ key: 'preview', node: previewBlock })
}
if (queue) {
sections.push({ key: 'queue', node: queue })
}
// Micro actions are the TOP-MOST thing in the whole overlay lane — above the
// status card, above the billing wall, above everything. They're the only
// rows up here you press instead of read, so nothing may ever stack on top
// of them. Rendered outside the card (below) so the pills float.
const visible = sections.length > 0
// No height to publish: the stack is an in-flow child of the composer dock,
// so the dock's own measurement (--composer-measured-height) already covers
// it and the thread clears both with one number.
if (!visible) {
return null
}
return (
<div
// In flow in the dock column, directly above the composer. The dock is
// bottom-anchored, so this grows upward over the thread without needing
// to be positioned — and it shares the dock's left edge for free.
className="flex max-h-[40vh] min-h-0 flex-col overflow-y-auto"
data-slot="composer-status-stack"
onPointerDownCapture={() => blurComposerInput()}
>
{/* The card paints the shared --composer-fill (rest / scrolled / focused
all match the composer surface by construction); on scroll we only
ghost the CONTENT — element opacity on the card would kill the blur.
Rounded top, square bottom; the bottom border is TRANSPARENT — the
composer surface's visible top border (which sits at a higher z) is the
single shared seam, so the two read as one fused capsule. */}
{sections.length > 0 && (
<div
className={cn(
composerDockCard('top'),
// Inset (mx-2) so the stack reads slightly narrower than the composer
// surface below it — the original look.
'mx-2 overflow-hidden rounded-b-none border-b border-b-transparent pt-0.5',
'transition-opacity duration-200 ease-out',
scrolledUp ? 'opacity-30 group-hover/composer:opacity-100' : 'opacity-100'
)}
>
{sections.map(section => (
<div key={section.key}>{section.node}</div>
))}
</div>
)}
</div>
)
}
@@ -0,0 +1,78 @@
import { cleanup, render } from '@testing-library/react'
import { MemoryRouter } from 'react-router'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { I18nProvider } from '@/i18n'
import { resetBackgroundPollingGuard } from '@/store/composer-status'
import { $gateway } from '@/store/gateway'
import { ComposerStatusStack } from './index'
// The stack measures itself into a surface var — jsdom has no ResizeObserver.
class ResizeObserverStub {
observe() {}
unobserve() {}
disconnect() {}
}
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
const SID = 'sess-dead-runtime'
function renderStack() {
return render(
<MemoryRouter>
<I18nProvider configClient={null} initialLocale="en">
<ComposerStatusStack queue={null} sessionId={SID} />
</I18nProvider>
</MemoryRouter>
)
}
// #98434: a boot-restored tile can stay bound to a dead runtime id and remount
// repeatedly (no genuine rebind ever happens). The mount effect used to clear
// the gone-polling latch on every mount, so each remount re-armed the 4001
// storm against that id forever.
describe('ComposerStatusStack dead-runtime remount', () => {
beforeEach(() => {
resetBackgroundPollingGuard()
})
afterEach(() => {
cleanup()
$gateway.set(null as never)
resetBackgroundPollingGuard()
})
it('does not re-poll process.list after a remount once the session is latched gone', async () => {
const request = vi.fn(async (method: string) => {
if (method === 'process.list') {
throw new Error('session not found')
}
return {}
})
const processListCalls = () => request.mock.calls.filter(([method]) => method === 'process.list').length
$gateway.set({ request } as never)
const first = renderStack()
await Promise.resolve()
await Promise.resolve()
expect(processListCalls()).toBe(1)
first.unmount()
const second = renderStack()
await Promise.resolve()
await Promise.resolve()
// Before the fix: the mount effect cleared the latch, so this remount
// re-fired process.list against the same dead id.
expect(processListCalls()).toBe(1)
second.unmount()
})
})
@@ -0,0 +1,155 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { $previewTabs, closeRightRail } from '@/store/preview'
import { $connection } from '@/store/session'
import { PreviewStatusRow } from './preview-row'
describe('PreviewStatusRow', () => {
beforeEach(() => {
$connection.set(null)
closeRightRail()
})
afterEach(() => {
cleanup()
$connection.set(null)
closeRightRail()
vi.restoreAllMocks()
})
it('keeps the preview tooltip label inline inside the portaled decoration', async () => {
const view = render(
<PreviewStatusRow
item={{ cwd: 'C:\\repo', id: 'preview.html', label: 'preview.html', target: 'preview.html' }}
onDismiss={() => undefined}
/>
)
fireEvent.pointerMove(screen.getByText('preview.html'), { pointerType: 'mouse' })
await screen.findByRole('tooltip')
const content = document.querySelector<HTMLElement>('[data-slot="tooltip-content"]')
const label = content?.firstElementChild?.firstElementChild
expect(content).not.toBeNull()
expect(view.container.contains(content)).toBe(false)
expect(label?.classList.contains('inline-flex')).toBe(true)
expect(label?.classList.contains('flex')).toBe(false)
})
it('opens remote non-HTML file artifacts in the in-app preview instead of the local browser bridge', async () => {
const remotePath = '/home/agent/report.pdf'
const openPreviewInBrowser = vi.fn(async () => undefined)
$connection.set({ mode: 'remote' } as never)
Object.defineProperty(window, 'hermesDesktop', {
configurable: true,
value: {
api: vi.fn(async () => ({ binary: true, byteSize: 42, mimeType: 'application/pdf' })),
normalizePreviewTarget: vi.fn(async () => ({
kind: 'file',
label: 'report.pdf',
path: remotePath,
previewKind: 'binary',
source: remotePath,
url: 'file:///home/agent/report.pdf'
})),
openPreviewInBrowser
}
})
render(
<PreviewStatusRow
item={{ cwd: '/home/agent', id: remotePath, label: 'report.pdf', target: remotePath }}
onDismiss={() => undefined}
/>
)
fireEvent.click(screen.getByText('report.pdf'))
await waitFor(() => {
expect($previewTabs.get()).toEqual([
expect.objectContaining({ target: expect.objectContaining({ kind: 'file', path: remotePath }) })
])
})
expect(openPreviewInBrowser).not.toHaveBeenCalled()
})
it('keeps local file artifacts on the browser bridge', async () => {
const localPath = '/Users/alice/report.pdf'
const openPreviewInBrowser = vi.fn(async () => undefined)
$connection.set({ mode: 'local' } as never)
Object.defineProperty(window, 'hermesDesktop', {
configurable: true,
value: {
normalizePreviewTarget: vi.fn(async () => ({
kind: 'file',
label: 'report.pdf',
path: localPath,
previewKind: 'binary',
source: localPath,
url: 'file:///Users/alice/report.pdf'
})),
openPreviewInBrowser
}
})
render(
<PreviewStatusRow
item={{ cwd: '/Users/alice', id: localPath, label: 'report.pdf', target: localPath }}
onDismiss={() => undefined}
/>
)
fireEvent.click(screen.getByText('report.pdf'))
await waitFor(() => {
expect(openPreviewInBrowser).toHaveBeenCalledWith('file:///Users/alice/report.pdf')
})
expect($previewTabs.get()).toEqual([])
})
it('keeps remote HTML on the staged browser-open path, not the in-app pane', async () => {
const remotePath = '/home/agent/index.html'
const html = '<!doctype html><html><body>hi</body></html>'
const dataUrl = `data:text/html;base64,${btoa(html)}`
const openPreviewInBrowser = vi.fn(async () => undefined)
const saveImageBuffer = vi.fn(async () => '/tmp/staged.html')
$connection.set({ mode: 'remote' } as never)
Object.defineProperty(window, 'hermesDesktop', {
configurable: true,
value: {
api: vi.fn(async () => dataUrl),
normalizePreviewTarget: vi.fn(async () => ({
kind: 'file',
label: 'index.html',
path: remotePath,
previewKind: 'html',
source: remotePath,
url: 'file:///home/agent/index.html'
})),
openPreviewInBrowser,
saveImageBuffer
}
})
render(
<PreviewStatusRow
item={{ cwd: '/home/agent', id: remotePath, label: 'index.html', target: remotePath }}
onDismiss={() => undefined}
/>
)
fireEvent.click(screen.getByText('index.html'))
await waitFor(() => {
expect(saveImageBuffer).toHaveBeenCalled()
expect(openPreviewInBrowser).toHaveBeenCalledWith('file:///tmp/staged.html')
})
expect($previewTabs.get()).toEqual([])
})
})
@@ -0,0 +1,136 @@
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 { isDesktopFsRemoteMode } from '@/lib/desktop-fs'
import { normalizeOrLocalPreviewTarget, openPreviewTargetInBrowser } from '@/lib/local-preview'
import { cn } from '@/lib/utils'
import { notifyError } from '@/store/notifications'
import { $previewTabSources, closePreviewForSource, openPreview } 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 openSources = useStore($previewTabSources)
const [opening, setOpening] = useState(false)
// A tab open IS a pane in the tree now, so its presence is the whole answer.
const isOpen = openSources.includes(item.target)
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) {
closePreviewForSource(item.target)
return
}
setOpening(true)
try {
openPreview(await resolveTarget(), 'tool-result')
} catch (error) {
notifyError(error, t.preview.unavailable)
} finally {
setOpening(false)
}
}
const openDefaultTarget = async () => {
try {
const target = await resolveTarget()
// A file:// URL resolved in remote mode names a file on the backend
// host, not on the machine running Electron. Keep local files and
// ordinary URLs on the browser path, but route remote files through the
// in-app preview pane so its filesystem adapter reads via the gateway.
// (Remote HTML stays on openPreviewTargetInBrowser, which stages a
// sanitized local copy before opening it.)
if (target.kind === 'file' && target.previewKind !== 'html' && isDesktopFsRemoteMode()) {
openPreview(target, 'tool-result')
return
}
await openPreviewTargetInBrowser(target)
} catch (error) {
notifyError(error, t.preview.unavailable)
}
}
return (
<StatusRow
leading={
<Codicon
aria-hidden
className={cn('text-muted-foreground/70', opening && 'animate-pulse')}
name="globe"
size="0.8rem"
/>
}
// Plain click opens the link in the browser, except remote files which
// only the in-app gateway-backed preview can read. ⌘/Ctrl-click always
// uses the in-app preview pane. (isOpen still toggles the pane closed.)
onActivate={event => {
if (event.metaKey || event.ctrlKey) {
void togglePreview()
} else {
void openDefaultTarget()
}
}}
trailing={
<Tip label={t.statusStack.dismiss}>
<Button
aria-label={t.statusStack.dismiss}
className="-my-1 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"
>
<Codicon name="close" size="0.75rem" />
</Button>
</Tip>
}
trailingVisible
>
<Tip
label={
// inline-flex (not flex): a block child collapses Tip's decoration
// wrapper geometry and mis-positions the tooltip (#62022).
<span className="inline-flex flex-col gap-0.5">
<span>{item.target}</span>
<span className="opacity-70">{t.preview.linkHint}</span>
</span>
}
>
<span className="min-w-0 truncate text-[0.73rem] leading-4 text-foreground/92">{item.label}</span>
</Tip>
</StatusRow>
)
})
@@ -0,0 +1,179 @@
import { Fragment, memo, type ReactNode } from 'react'
import { openAgentTerminal } from '@/app/right-sidebar/terminal/terminals'
import { StatusRow } from '@/components/chat/status-row'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { GlyphSpinner } from '@/components/ui/glyph-spinner'
import { Tip } from '@/components/ui/tooltip'
import { type Translations, useI18n } from '@/i18n'
import { capitalize } from '@/lib/text'
import type { TodoStatus } from '@/lib/todos'
import { cn } from '@/lib/utils'
import type { ComposerStatusItem } from '@/store/composer-status'
const toolLabel = (name: string) => name.split('_').filter(Boolean).map(capitalize).join(' ') || name
// Todo rows speak checkbox, not spinner-and-dot: a dashed ring while the item
// is still open (pending), codicons once it resolves, a live spinner only on
// the in-progress item.
const TODO_GLYPHS: Record<Exclude<TodoStatus, 'in_progress' | 'pending'>, { icon: string; tone: string }> = {
cancelled: { icon: 'circle-slash', tone: 'text-muted-foreground/45' },
completed: { icon: 'pass-filled', tone: 'text-emerald-500/80' }
}
// Left slot: braille spinner while running, otherwise a small status dot
// (green = done, red = failed) so the slot is always filled and rows align.
function leadingGlyph(item: ComposerStatusItem, s: Translations['statusStack']): ReactNode {
if (item.type === 'goal') {
if (item.goalStatus === 'paused') {
return <Codicon className="text-muted-foreground/60" name="debug-pause" size="0.8rem" />
}
if (item.goalStatus === 'done') {
return <Codicon className="text-emerald-500/80" name="pass-filled" size="0.8rem" />
}
return (
<GlyphSpinner
ariaLabel={s.running}
className="text-[0.85rem] leading-none text-emerald-500/80"
spinner="braille"
/>
)
}
if (item.todoStatus === 'pending') {
return (
<span
aria-hidden
className="box-border size-[0.7rem] rounded-full border border-dashed border-muted-foreground/60"
/>
)
}
if (item.todoStatus && item.todoStatus !== 'in_progress') {
const glyph = TODO_GLYPHS[item.todoStatus]
return <Codicon className={glyph.tone} name={glyph.icon} size="0.8rem" />
}
if (item.state === 'running') {
return (
<GlyphSpinner
ariaLabel={s.running}
className="text-[0.85rem] leading-none text-muted-foreground/80"
spinner="braille"
/>
)
}
return (
<span
aria-hidden
className={cn('size-1.5 rounded-full', item.state === 'failed' ? 'bg-destructive/80' : 'bg-emerald-500/70')}
/>
)
}
interface StatusItemRowProps {
item: ComposerStatusItem
/** Clear a finished background task from the stack. */
onDismiss?: (id: string) => void
/** Open the subagent's own session window, livestreamed by the gateway's
* child-session mirror (Agents view fallback for older gateways). */
onOpen?: () => void
/** Cancel a running background task. */
onStop?: (id: string) => void
}
/**
* Renders one {@link ComposerStatusItem} into the shared {@link StatusRow}.
* Memoised + keyed by id so parent re-renders never remount it (the spinner
* keeps ticking instead of resetting).
*/
export const StatusItemRow = memo(function StatusItemRow({ item, onDismiss, onOpen, onStop }: StatusItemRowProps) {
const { t } = useI18n()
const s = t.statusStack
const failed = item.state === 'failed'
const running = item.state === 'running'
const action =
item.type === 'background'
? running
? onStop && { label: s.stop, onClick: () => onStop(item.id) }
: onDismiss && { label: s.dismiss, onClick: () => onDismiss(item.id) }
: null
const canOpen = item.type === 'subagent' && !!onOpen
// Background rows link to their read-only terminal tab; subagents open their session.
const onActivate =
item.type === 'background' ? () => openAgentTerminal(item.id, item.title) : canOpen ? onOpen : undefined
return (
<Fragment>
<StatusRow
leading={
item.depth ? (
<span className="flex items-center" style={{ paddingLeft: `${Math.min(item.depth, 4) * 0.8}rem` }}>
{leadingGlyph(item, s)}
</span>
) : (
leadingGlyph(item, s)
)
}
onActivate={onActivate}
trailing={
action ? (
<Tip label={action.label}>
<Button
aria-label={action.label}
className="-my-1 size-4 rounded-md text-muted-foreground/60 hover:text-foreground/90"
onClick={event => {
event.stopPropagation()
action.onClick()
}}
size="icon-xs"
type="button"
variant="ghost"
>
<Codicon name="close" size="0.75rem" />
</Button>
</Tip>
) : canOpen ? (
<Codicon aria-hidden className="text-muted-foreground/55" name="link-external" size="0.85rem" />
) : undefined
}
>
<span
className={cn(
'min-w-0 flex-1 truncate text-[0.73rem] leading-4',
failed
? 'text-destructive/90'
: item.todoStatus && item.todoStatus !== 'in_progress'
? 'text-muted-foreground/75'
: 'text-foreground/92'
)}
>
{item.title}
</span>
{item.type === 'subagent' && item.currentTool && (
<span className="shrink-0 truncate text-[0.62rem] leading-4 text-muted-foreground/70">
{toolLabel(item.currentTool)}
</span>
)}
{item.type === 'goal' && item.currentTool && (
<span className="shrink-0 truncate text-[0.62rem] leading-4 text-muted-foreground/70">
{item.currentTool}
</span>
)}
{failed && typeof item.exitCode === 'number' && item.exitCode !== 0 && (
<span className="shrink-0 rounded bg-destructive/15 px-1 text-[0.58rem] font-semibold text-destructive tabular-nums">
{s.exit(item.exitCode)}
</span>
)}
</StatusRow>
</Fragment>
)
})
@@ -0,0 +1,183 @@
import { act, render } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { $composerSuggestionsBySession, type ComposerSuggestion, offerSuggestions } from '@/store/composer-suggestions'
import { SuggestionPills } from './suggestion-pills'
/**
* The pill strip owns phase; the bus owns existence. These cover the seam
* between them — what has to happen to a pill's phase and its in-flight work
* when the bus withdraws it, which is the normal way a suggestion ends.
*/
interface Flow {
resolve: () => void
reject: (error: unknown) => void
/** Latest value of the invoke context's `cancelled()` poll. */
cancelled: () => boolean
}
function pill(id: string, provider = 'mcp'): { suggestion: ComposerSuggestion; flow: Flow } {
const flow: Partial<Flow> = {}
const suggestion: ComposerSuggestion = {
doneLabel: `Added ${id}`,
doneTip: 'done',
id,
invoke: context =>
new Promise<void>((resolve, reject) => {
flow.resolve = resolve
flow.reject = reject
flow.cancelled = context.cancelled
}),
label: `Add ${id}`,
provider,
tip: 'because',
workingLabel: `Connecting ${id}…`,
workingTip: 'cancel'
}
return { flow: flow as Flow, suggestion }
}
const labels = (container: HTMLElement) => [...container.querySelectorAll('button')].map(button => button.textContent)
const click = async (container: HTMLElement) => {
await act(async () => {
container.querySelector('button')!.click()
})
}
afterEach(() => {
$composerSuggestionsBySession.set({})
})
describe('SuggestionPills', () => {
it('narrates idle → working → done through one invoke', async () => {
const { flow, suggestion } = pill('github')
const { container } = render(<SuggestionPills sessionId="s1" />)
act(() => offerSuggestions('s1', 'mcp', [suggestion]))
expect(labels(container)).toEqual(['Add github'])
await click(container)
expect(labels(container)).toEqual(['Connecting github…'])
await act(async () => flow.resolve())
expect(labels(container)).toEqual(['Added github'])
})
it('re-offering a withdrawn key starts from idle, not a stale done', async () => {
const first = pill('github')
const { container } = render(<SuggestionPills sessionId="s1" />)
act(() => offerSuggestions('s1', 'mcp', [first.suggestion]))
await click(container)
await act(async () => first.flow.resolve())
expect(labels(container)).toEqual(['Added github'])
// The provider withdraws (draft lost the trigger) and offers again later.
act(() => offerSuggestions('s1', 'mcp', []))
act(() => offerSuggestions('s1', 'mcp', [pill('github').suggestion]))
expect(labels(container)).toEqual(['Add github'])
})
it('a fresh offer is clickable again after an earlier one completed', async () => {
const first = pill('github')
const second = pill('github')
const { container } = render(<SuggestionPills sessionId="s1" />)
act(() => offerSuggestions('s1', 'mcp', [first.suggestion]))
await click(container)
await act(async () => first.flow.resolve())
act(() => offerSuggestions('s1', 'mcp', []))
act(() => offerSuggestions('s1', 'mcp', [second.suggestion]))
await click(container)
// A stale `done` swallows the click (only idle invokes) — this is the
// user-visible half of the phase leak.
expect(labels(container)).toEqual(['Connecting github…'])
})
it('cancels the in-flight action when the pill is withdrawn mid-flight', async () => {
const { flow, suggestion } = pill('github')
const { container } = render(<SuggestionPills sessionId="s1" />)
act(() => offerSuggestions('s1', 'mcp', [suggestion]))
await click(container)
expect(flow.cancelled()).toBe(false)
// The pill is the only cancel affordance; losing it must abort the work.
act(() => offerSuggestions('s1', 'mcp', []))
expect(labels(container)).toEqual([])
expect(flow.cancelled()).toBe(true)
})
it('cancels in-flight work when the strip unmounts', async () => {
const { flow, suggestion } = pill('github')
const { container, unmount } = render(<SuggestionPills sessionId="s1" />)
act(() => offerSuggestions('s1', 'mcp', [suggestion]))
await click(container)
unmount()
expect(flow.cancelled()).toBe(true)
})
it('does not carry phase across a session switch on one mounted composer', async () => {
const { flow, suggestion } = pill('github')
const { container, rerender } = render(<SuggestionPills sessionId="s1" />)
act(() => offerSuggestions('s1', 'mcp', [suggestion]))
await click(container)
await act(async () => flow.resolve())
expect(labels(container)).toEqual(['Added github'])
act(() => offerSuggestions('s2', 'mcp', [pill('github').suggestion]))
rerender(<SuggestionPills sessionId="s2" />)
expect(labels(container)).toEqual(['Add github'])
})
it('returns to idle when the provider rejects, so it can be retried', async () => {
const { flow, suggestion } = pill('github')
const { container } = render(<SuggestionPills sessionId="s1" />)
act(() => offerSuggestions('s1', 'mcp', [suggestion]))
await click(container)
await act(async () => flow.reject(new Error('nope')))
expect(labels(container)).toEqual(['Add github'])
})
it('a second click on a working pill requests cancel', async () => {
const { flow, suggestion } = pill('github')
const { container } = render(<SuggestionPills sessionId="s1" />)
act(() => offerSuggestions('s1', 'mcp', [suggestion]))
await click(container)
await click(container)
expect(flow.cancelled()).toBe(true)
expect(labels(container)).toEqual(['Connecting github…'])
})
it('invokes each pill independently when two are offered', async () => {
const a = pill('github')
const b = pill('linear')
const invoke = vi.spyOn(b.suggestion, 'invoke')
const { container } = render(<SuggestionPills sessionId="s1" />)
act(() => offerSuggestions('s1', 'mcp', [a.suggestion, b.suggestion]))
await click(container)
expect(labels(container)).toEqual(['Connecting github…', 'Add linear'])
expect(invoke).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,159 @@
import { useEffect, useState } from 'react'
import { composerFloatingPill } from '@/components/chat/composer-dock'
import { Codicon } from '@/components/ui/codicon'
import { Tip } from '@/components/ui/tooltip'
import { triggerHaptic } from '@/lib/haptics'
import { brandFor, brandGlyphStyle } from '@/lib/mcp-brands'
import { useSessionSlice } from '@/lib/use-session-slice'
import { cn } from '@/lib/utils'
import { $composerSuggestionsBySession, markSuggestionInvoked, suggestionKey } from '@/store/composer-suggestions'
/**
* The composer suggestion strip — generic pills fed by the suggestion bus
* (`store/composer-suggestions.ts`; the MCP connect pills of PR #85036 are
* provider one of N). Renders beside the micro-action badges in the floating
* lane above the composer, same pill treatment. Session-scoped like the
* badges: each composer shows only the suggestions its own session earned.
*
* Every pill is a one-click action with a narrated lifecycle: label →
* workingLabel (click again to request cancel) → doneLabel. The provider's
* `invoke` owns the work, cancellation, rollback, and error toasts; this
* component owns only the phase presentation.
*
* No dismiss affordance ON PURPOSE. Suggestions are self-limiting — a
* provider withdraws its offer when the trigger condition stops holding —
* so a close button would mostly collect accidental permanent opt-outs.
* The escape hatch is simply not clicking.
*
* Same pointer-events rule as the micro-action pills: NEVER
* `pointer-events-none` — the pop-out drag region sits behind this strip.
*/
type PillPhase = 'done' | 'idle' | 'working'
/**
* Phase belongs to the pill the user is looking at, so it lives and dies with
* that pill — the bus owns whether a suggestion exists, this owns only how far
* along it is. Remounting per session enforces the half of that the strip
* can't see: one composer stays mounted across a session switch, and phase
* keys are `provider:id`, so without this "Added GitHub" in one chat renders
* the next chat's genuine offer as already-done and inert.
*/
export function SuggestionPills({ sessionId }: { sessionId: null | string }) {
return <SessionSuggestionPills key={sessionId ?? ''} sessionId={sessionId} />
}
function SessionSuggestionPills({ sessionId }: { sessionId: null | string }) {
const suggestions = useSessionSlice($composerSuggestionsBySession, sessionId)
const [phases, setPhases] = useState<Record<string, PillPhase>>({})
// Cancel flags outlive renders but never trigger them (poll-boundary abort).
const [cancels] = useState(() => new Map<string, boolean>())
const setPhase = (key: string, phase: PillPhase) => setPhases(current => ({ ...current, [key]: phase }))
// A pill that leaves the strip takes its phase with it, and cancels whatever
// it had in flight. Both halves matter:
//
// - Phase, because keys repeat. A provider withdraws and re-offers the same
// `provider:id` constantly (the draft loses and regains the trigger word,
// a connect fails and the word is still typed), and a `done` left on file
// paints the fresh offer as "Added GitHub" — a pill that says it already
// worked and ignores clicks, since only `idle` invokes.
// - Cancellation, because a withdrawn pill is the user's ONLY cancel
// affordance. Clicking a working pill sets this flag; once the pill is
// gone nothing can, so an OAuth flow the user walked away from would poll
// forever, hold the server's in-progress slot against a retry, and either
// land a server they never confirmed or roll one back with no UI to say
// so. Withdrawal aborts it at the next poll boundary and lets the
// provider roll back.
//
// `suggestions` is reference-stable while the offered set is unchanged (the
// bus preserves identity on no-op writes), so this runs on real churn only.
useEffect(() => {
const live = new Set(suggestions.map(suggestionKey))
for (const key of cancels.keys()) {
if (!live.has(key)) {
cancels.set(key, true)
}
}
setPhases(current => {
const kept = Object.entries(current).filter(([key]) => live.has(key))
return kept.length === Object.keys(current).length ? current : Object.fromEntries(kept)
})
}, [cancels, suggestions])
// Unmount withdraws everything at once (composer closing, session switch
// remounting this subtree) — same rule, same reason.
useEffect(
() => () => {
for (const key of cancels.keys()) {
cancels.set(key, true)
}
},
[cancels]
)
return suggestions.map(suggestion => {
const key = suggestionKey(suggestion)
const brand = suggestion.brand ? brandFor(suggestion.brand) : null
const phase = phases[key] ?? 'idle'
const label =
phase === 'working' ? suggestion.workingLabel : phase === 'done' ? suggestion.doneLabel : suggestion.label
const tip = phase === 'working' ? suggestion.workingTip : phase === 'done' ? suggestion.doneTip : suggestion.tip
const invoke = async () => {
cancels.set(key, false)
setPhase(key, 'working')
triggerHaptic('selection')
// Acting on a pill clears its ignored-count in the bus's declined
// ledger — its later withdrawal is success, not a strike.
markSuggestionInvoked(sessionId, key)
try {
await suggestion.invoke({ cancelled: () => cancels.get(key) === true, sessionId })
triggerHaptic('submit')
setPhase(key, 'done')
} catch {
// Provider owns error surfacing (and swallows its own cancels);
// the pill just returns to idle so it can be tried again.
setPhase(key, 'idle')
} finally {
cancels.delete(key)
}
}
return (
<Tip key={key} label={tip}>
<button
className={cn(composerFloatingPill, 'max-w-56', phase === 'done' && 'cursor-default')}
onClick={() => {
if (phase === 'working') {
// Second click requests cancel (a stuck OAuth tab, etc.).
cancels.set(key, true)
} else if (phase === 'idle') {
void invoke()
}
}}
type="button"
>
{phase === 'working' ? (
<Codicon className="shrink-0 opacity-70" name="loading" size="0.75rem" spinning />
) : phase === 'done' ? (
<Codicon className="shrink-0 text-emerald-400" name="check" size="0.75rem" />
) : brand ? (
<brand.Icon aria-hidden className="size-3 shrink-0" style={brandGlyphStyle(brand)} />
) : (
<Codicon className="shrink-0 opacity-70" name={suggestion.icon ?? 'lightbulb'} size="0.75rem" />
)}
<span className="truncate">{label}</span>
</button>
</Tip>
)
})
}
Loaded 100 of 11340 files, more files were not shown because too many files have changed in this diff. Show more