Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
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>
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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> = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }
|
||||
|
||||
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
Reference in new issue
Block a user