mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
feat(desktop): honor busy input mode
This commit is contained in:
parent
de5ece9944
commit
742ecb527a
19 changed files with 263 additions and 95 deletions
|
|
@ -86,7 +86,7 @@ test.describe('chat interaction with mock backend', () => {
|
|||
await expectVisualSnapshot(fixture!.page, { name: 'chat-with-messages', app: fixture!.app })
|
||||
})
|
||||
|
||||
test('offers stop, steer, and queue actions while busy', async ({}, testInfo) => {
|
||||
test('offers stop, redirect, and queue actions while busy', async ({}, testInfo) => {
|
||||
const page = fixture!.page
|
||||
const composer = page.locator('[contenteditable="true"]').first()
|
||||
const primary = page.locator('[data-slot="composer-root"] button[type="submit"]')
|
||||
|
|
@ -106,7 +106,7 @@ test.describe('chat interaction with mock backend', () => {
|
|||
|
||||
await composer.click()
|
||||
await composer.type('please answer tersely')
|
||||
await expect(primary).toHaveAttribute('aria-label', /Steer/)
|
||||
await expect(primary).toHaveAttribute('aria-label', /Redirect/)
|
||||
await expect(dictation).toBeVisible()
|
||||
await expect(speakReplies).toBeVisible()
|
||||
await expect(queue).toBeVisible()
|
||||
|
|
@ -120,10 +120,10 @@ test.describe('chat interaction with mock backend', () => {
|
|||
expect(controlLabels.indexOf('Voice dictation')).toBeLessThan(speakRepliesIndex)
|
||||
expect(speakRepliesIndex).toBeLessThan(controlLabels.indexOf('Queue message'))
|
||||
expect(controlLabels.indexOf('Queue message')).toBeLessThan(
|
||||
controlLabels.findIndex(label => label?.startsWith('Steer'))
|
||||
controlLabels.findIndex(label => label?.startsWith('Redirect'))
|
||||
)
|
||||
await page.screenshot({ path: testInfo.outputPath('busy-composer-steer.png') })
|
||||
await expect(primary.locator('svg.tabler-icon-steering-wheel')).toBeVisible()
|
||||
await page.screenshot({ path: testInfo.outputPath('busy-composer-redirect.png') })
|
||||
await expect(primary.locator('svg.tabler-icon-git-branch')).toBeVisible()
|
||||
|
||||
await queue.click()
|
||||
await expect(primary).toHaveAttribute('aria-label', 'Stop')
|
||||
|
|
|
|||
|
|
@ -29,14 +29,13 @@ async function send(page: Page, text: string): Promise<void> {
|
|||
await page.keyboard.press('Enter')
|
||||
}
|
||||
|
||||
async function steer(page: Page, text: string): Promise<void> {
|
||||
async function redirect(page: Page, text: string): Promise<void> {
|
||||
const composer = page.locator('[contenteditable="true"]').first()
|
||||
const primary = page.locator('[data-slot="composer-root"] button[type="submit"]')
|
||||
|
||||
await composer.waitFor({ state: 'visible', timeout: 15_000 })
|
||||
await composer.click()
|
||||
await composer.type(text, { delay: 5 })
|
||||
await expect(primary).toHaveAttribute('aria-label', /Steer/)
|
||||
await primary.click()
|
||||
}
|
||||
|
||||
|
|
@ -157,9 +156,9 @@ test.describe('correction session switch', () => {
|
|||
await waitForTranscriptText(page, TOOL_STARTED)
|
||||
await waitForTranscriptText(page, ORIGINAL_PROMPT)
|
||||
|
||||
// The historical session redirects while a foreground terminal task is
|
||||
// running. Use the visible Steer action to cover the real composer path.
|
||||
await steer(page, CORRECTION)
|
||||
// Redirect immediately while the foreground terminal task is still live.
|
||||
// Waiting for an old control label here races the action into a plain Send.
|
||||
await redirect(page, CORRECTION)
|
||||
await waitForTranscriptText(page, CORRECTION)
|
||||
|
||||
const orderBeforeSwitch = relevantOrder(await transcriptTextOrder(page))
|
||||
|
|
|
|||
|
|
@ -12,9 +12,9 @@ import { expect, test, type Page } from './test'
|
|||
import { type MockBackendFixture, setupMockBackend, waitForAppReady } from './fixtures'
|
||||
import { MOCK_REPLY } from './mock-server'
|
||||
|
||||
const ACTIVE_PROMPT = 'E2E_QUEUE_TURN_BOUNDARY_ACTIVE'
|
||||
const ACTIVE_PROMPT = 'write me a short story'
|
||||
const QUEUED_PROMPT = 'E2E_QUEUE_TURN_BOUNDARY_QUEUED'
|
||||
const STEER_PROMPT = 'E2E_STEER_TURN_BOUNDARY_CORRECTION'
|
||||
const STEER_PROMPT = 'actually just say hi'
|
||||
|
||||
async function send(page: Page, text: string): Promise<void> {
|
||||
const composer = page.locator('[contenteditable="true"]').first()
|
||||
|
|
@ -100,7 +100,7 @@ test.describe('queued prompt turn boundary', () => {
|
|||
await expect.poll(() => mock.receivedPrompts.filter(prompt => prompt === QUEUED_PROMPT)).toHaveLength(1)
|
||||
})
|
||||
|
||||
test('places a steer prompt before the reply it redirects', async () => {
|
||||
test('redirect replaces the live turn once and restarts inference with the correction', async () => {
|
||||
const { mock, page } = fixture!
|
||||
|
||||
await send(page, ACTIVE_PROMPT)
|
||||
|
|
@ -114,6 +114,9 @@ test.describe('queued prompt turn boundary', () => {
|
|||
{ timeout: 60_000 }
|
||||
)
|
||||
|
||||
expect(steerTurnOrder(await transcriptMessageOrder(page))).toEqual([ACTIVE_PROMPT, STEER_PROMPT, MOCK_REPLY])
|
||||
const messages = await transcriptMessageOrder(page)
|
||||
expect(steerTurnOrder(messages)).toEqual([ACTIVE_PROMPT, STEER_PROMPT, MOCK_REPLY])
|
||||
await expect.poll(() => mock.receivedPrompts.filter(prompt => prompt === ACTIVE_PROMPT || prompt === STEER_PROMPT))
|
||||
.toEqual([ACTIVE_PROMPT, STEER_PROMPT])
|
||||
})
|
||||
})
|
||||
|
|
@ -130,10 +130,13 @@ auxiliary:
|
|||
await fixture.mock.waitForHeldCompletion()
|
||||
await expect(page.getByRole('status', { name: 'Summarizing thread' }).last()).toBeVisible()
|
||||
|
||||
const composer = page.locator('[contenteditable="true"]').first()
|
||||
await composer.click()
|
||||
await composer.type(queued)
|
||||
const primary = page.locator('[data-slot="composer-root"] button[type="submit"]')
|
||||
await expect(primary).toHaveAttribute('aria-label', 'Queue message')
|
||||
|
||||
await send(page, queued)
|
||||
await page.keyboard.press('Enter')
|
||||
await expect(page.getByText('1 Queued')).toBeVisible()
|
||||
expect(fixture.mock.heldCompletionCount()).toBe(1)
|
||||
expect(receivedUserTexts()).not.toContain(queued)
|
||||
|
|
|
|||
44
apps/desktop/src/app/chat/composer/busy-input-mode.ts
Normal file
44
apps/desktop/src/app/chat/composer/busy-input-mode.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
export type BusyInputMode = 'interrupt' | 'queue' | 'steer'
|
||||
export type BusyComposerAction = 'redirect' | 'queue' | 'steer' | 'stop'
|
||||
|
||||
export const normalizeBusyInputMode = (value: unknown): BusyInputMode =>
|
||||
value === 'queue' || value === 'steer' ? value : 'interrupt'
|
||||
|
||||
/**
|
||||
* Maps the persisted cross-surface busy-input policy to Desktop's visible
|
||||
* composer action. `interrupt` is the historical config spelling; capable
|
||||
* desktop sessions implement it as an active-turn redirect.
|
||||
*/
|
||||
export function resolveBusyComposerAction({
|
||||
busy,
|
||||
canRedirect,
|
||||
canSteer,
|
||||
compacting,
|
||||
hasPayload,
|
||||
mode
|
||||
}: {
|
||||
busy: boolean
|
||||
canRedirect: boolean
|
||||
canSteer: boolean
|
||||
compacting: boolean
|
||||
hasPayload: boolean
|
||||
mode: BusyInputMode
|
||||
}): BusyComposerAction {
|
||||
if (!busy) {
|
||||
return 'stop'
|
||||
}
|
||||
|
||||
if (mode === 'queue') {
|
||||
return hasPayload ? 'queue' : 'stop'
|
||||
}
|
||||
|
||||
if (!compacting && mode === 'interrupt' && canRedirect) {
|
||||
return 'redirect'
|
||||
}
|
||||
|
||||
if (!compacting && mode === 'steer' && canSteer) {
|
||||
return 'steer'
|
||||
}
|
||||
|
||||
return hasPayload ? 'queue' : 'stop'
|
||||
}
|
||||
|
|
@ -71,9 +71,21 @@ describe('ComposerControls shortcut tooltips', () => {
|
|||
await expectShortcutTooltip('Steer the current run', '↵')
|
||||
})
|
||||
|
||||
it('shows Ctrl+Enter for Queue', async () => {
|
||||
it('shows Enter for Queue when queue is the configured busy action', async () => {
|
||||
renderControls({ busy: true, busyAction: 'queue' })
|
||||
|
||||
await expectShortcutTooltip('Queue message', '↵')
|
||||
})
|
||||
|
||||
it('shows Redirect on Enter', async () => {
|
||||
renderControls({ busy: true, busyAction: 'redirect' })
|
||||
|
||||
await expectShortcutTooltip('Redirect the current run', '↵')
|
||||
})
|
||||
|
||||
it('shows the secondary Queue shortcut beside Redirect', async () => {
|
||||
renderControls({ busy: true, busyAction: 'redirect' })
|
||||
|
||||
await expectShortcutTooltip('Queue message', 'Ctrl+↵')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { Codicon } from '@/components/ui/codicon'
|
|||
import { Tip, TipKeybindLabel } from '@/components/ui/tooltip'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { AudioLines, iconSize, Layers3, Loader2, Square, SteeringWheel, Volume2, VolumeX } from '@/lib/icons'
|
||||
import { AudioLines, GitBranch, iconSize, Layers3, Loader2, Square, SteeringWheel, Volume2, VolumeX } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import type { ConversationStatus } from './hooks/use-voice-conversation'
|
||||
|
|
@ -53,7 +53,7 @@ export function ComposerControls({
|
|||
}: {
|
||||
autoSpeak: boolean
|
||||
busy: boolean
|
||||
busyAction: 'steer' | 'queue' | 'stop'
|
||||
busyAction: 'redirect' | 'steer' | 'queue' | 'stop'
|
||||
canSubmit: boolean
|
||||
compactModelPill?: boolean
|
||||
conversation: ConversationProps
|
||||
|
|
@ -73,15 +73,16 @@ export function ComposerControls({
|
|||
}
|
||||
|
||||
const showVoicePrimary = !busy && !hasComposerPayload
|
||||
const busyLabel = busyAction === 'queue' ? c.queueMessage : busyAction === 'steer' ? c.steer : c.stop
|
||||
const busyLabel =
|
||||
busyAction === 'queue' ? c.queueMessage : busyAction === 'redirect' ? c.redirect : busyAction === 'steer' ? c.steer : c.stop
|
||||
|
||||
return (
|
||||
<div className="ml-auto flex shrink-0 items-center gap-(--composer-control-gap)">
|
||||
<ModelPill compact={compactModelPill} disabled={disabled} model={state.model} />
|
||||
<DictationButton disabled={disabled} onToggle={onDictate} state={state.voice} status={voiceStatus} />
|
||||
<AutoSpeakButton active={autoSpeak} disabled={disabled} onToggle={onToggleAutoSpeak} />
|
||||
{busyAction === 'steer' ? (
|
||||
<Tip label={<TipKeybindLabel actionId="composer.queue" text={c.queueMessage} />}>
|
||||
{busyAction === 'redirect' || busyAction === 'steer' ? (
|
||||
<Tip label={<TipKeybindLabel actionId="composer.queue" combo="mod+enter" text={c.queueMessage} />}>
|
||||
<Button
|
||||
aria-label={c.queueMessage}
|
||||
className={GHOST_ICON_BTN}
|
||||
|
|
@ -116,13 +117,12 @@ export function ComposerControls({
|
|||
label={
|
||||
busy ? (
|
||||
<TipKeybindLabel
|
||||
actionId={
|
||||
busyAction === 'steer'
|
||||
? 'composer.steer'
|
||||
: busyAction === 'queue'
|
||||
? 'composer.queue'
|
||||
: 'composer.send'
|
||||
}
|
||||
actionId={busyAction === 'redirect' ? 'composer.redirect' : busyAction === 'steer'
|
||||
? 'composer.steer'
|
||||
: busyAction === 'queue'
|
||||
? 'composer.queue'
|
||||
: 'composer.send'}
|
||||
combo="enter"
|
||||
text={busyLabel}
|
||||
/>
|
||||
) : (
|
||||
|
|
@ -139,6 +139,8 @@ export function ComposerControls({
|
|||
{busy ? (
|
||||
busyAction === 'queue' ? (
|
||||
<Layers3 className={iconSize.sm} />
|
||||
) : busyAction === 'redirect' ? (
|
||||
<GitBranch className={iconSize.sm} />
|
||||
) : busyAction === 'steer' ? (
|
||||
<SteeringWheel className={iconSize.sm} />
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { useComposerSubmit } from './use-composer-submit'
|
|||
interface SubmitHarnessOptions {
|
||||
attachments?: ComposerAttachment[]
|
||||
busy?: boolean
|
||||
busyInputMode?: 'interrupt' | 'queue' | 'steer'
|
||||
compacting?: boolean
|
||||
text?: string
|
||||
}
|
||||
|
|
@ -15,6 +16,7 @@ interface SubmitHarnessOptions {
|
|||
function renderSubmitHook({
|
||||
attachments = [],
|
||||
busy = false,
|
||||
busyInputMode = 'interrupt',
|
||||
compacting = false,
|
||||
text = ''
|
||||
}: SubmitHarnessOptions = {}) {
|
||||
|
|
@ -25,6 +27,7 @@ function renderSubmitHook({
|
|||
const editorRef = { current: editor }
|
||||
const onCancel = vi.fn()
|
||||
const onSteer = vi.fn(async () => true)
|
||||
const onToolSteer = vi.fn(async () => true)
|
||||
const onSubmit = vi.fn(async () => true)
|
||||
const queueCurrentDraft = vi.fn(() => true)
|
||||
|
||||
|
|
@ -39,6 +42,7 @@ function renderSubmitHook({
|
|||
activeQueueSessionKeyRef: { current: 'stored-session' },
|
||||
attachments,
|
||||
busy,
|
||||
busyInputMode,
|
||||
compacting,
|
||||
clearDraft,
|
||||
disabled: false,
|
||||
|
|
@ -52,6 +56,7 @@ function renderSubmitHook({
|
|||
onCancel,
|
||||
onSteer,
|
||||
onSubmit,
|
||||
onToolSteer,
|
||||
queueCurrentDraft,
|
||||
queueEdit: null,
|
||||
queuedPrompts: [],
|
||||
|
|
@ -61,7 +66,7 @@ function renderSubmitHook({
|
|||
})
|
||||
)
|
||||
|
||||
return { clearDraft, hook, onCancel, onSteer, onSubmit, queueCurrentDraft }
|
||||
return { clearDraft, hook, onCancel, onSteer, onSubmit, onToolSteer, queueCurrentDraft }
|
||||
}
|
||||
|
||||
describe('useComposerSubmit busy-turn routing', () => {
|
||||
|
|
@ -70,7 +75,7 @@ describe('useComposerSubmit busy-turn routing', () => {
|
|||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('steers a plain-text follow-up instead of queueing or stopping', async () => {
|
||||
it('redirects a plain-text follow-up in interrupt mode instead of queueing or stopping', async () => {
|
||||
const { hook, onCancel, onSteer, onSubmit, queueCurrentDraft } = renderSubmitHook({
|
||||
busy: true,
|
||||
text: 'change course'
|
||||
|
|
@ -86,6 +91,38 @@ describe('useComposerSubmit busy-turn routing', () => {
|
|||
expect(onSubmit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('queues a plain-text follow-up in queue mode', () => {
|
||||
const { hook, onSteer, onToolSteer, queueCurrentDraft } = renderSubmitHook({
|
||||
busy: true,
|
||||
busyInputMode: 'queue',
|
||||
text: 'wait for it'
|
||||
})
|
||||
|
||||
act(() => {
|
||||
hook.result.current.submitDraft()
|
||||
})
|
||||
|
||||
expect(queueCurrentDraft).toHaveBeenCalledTimes(1)
|
||||
expect(onSteer).not.toHaveBeenCalled()
|
||||
expect(onToolSteer).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses tool-boundary steering in steer mode', async () => {
|
||||
const { hook, onSteer, onToolSteer, queueCurrentDraft } = renderSubmitHook({
|
||||
busy: true,
|
||||
busyInputMode: 'steer',
|
||||
text: 'after the next tool call'
|
||||
})
|
||||
|
||||
act(() => {
|
||||
hook.result.current.submitDraft()
|
||||
})
|
||||
|
||||
await waitFor(() => expect(onToolSteer).toHaveBeenCalledWith('after the next tool call'))
|
||||
expect(onSteer).not.toHaveBeenCalled()
|
||||
expect(queueCurrentDraft).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('queues a plain-text follow-up while the active turn is compacting', () => {
|
||||
const { hook, onCancel, onSteer, onSubmit, queueCurrentDraft } = renderSubmitHook({
|
||||
busy: true,
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { clearSessionDraft, type ComposerAttachment } from '@/store/composer'
|
|||
import { resetBrowseState } from '@/store/composer-input-history'
|
||||
import { enqueueQueuedPrompt, type QueuedPromptEntry } from '@/store/composer-queue'
|
||||
|
||||
import type { BusyInputMode } from '../busy-input-mode'
|
||||
import { cloneAttachments, type QueueEditState } from '../composer-utils'
|
||||
import { onComposerSubmitRequest } from '../focus'
|
||||
import { composerPlainText } from '../rich-editor'
|
||||
|
|
@ -17,6 +18,7 @@ interface UseComposerSubmitArgs {
|
|||
activeQueueSessionKeyRef: RefObject<string | null>
|
||||
attachments: ComposerAttachment[]
|
||||
busy: boolean
|
||||
busyInputMode: BusyInputMode
|
||||
compacting: boolean
|
||||
clearDraft: () => void
|
||||
disabled: boolean
|
||||
|
|
@ -30,6 +32,7 @@ interface UseComposerSubmitArgs {
|
|||
onCancel: ChatBarProps['onCancel']
|
||||
onSteer: ChatBarProps['onSteer']
|
||||
onSubmit: ChatBarProps['onSubmit']
|
||||
onToolSteer: ChatBarProps['onToolSteer']
|
||||
queueCurrentDraft: () => boolean
|
||||
queueEdit: QueueEditState | null
|
||||
queuedPrompts: QueuedPromptEntry[]
|
||||
|
|
@ -43,15 +46,14 @@ interface UseComposerSubmitArgs {
|
|||
* 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.
|
||||
* rejects, so nothing is ever lost).
|
||||
*/
|
||||
export function useComposerSubmit({
|
||||
activeQueueSessionKey,
|
||||
activeQueueSessionKeyRef,
|
||||
attachments,
|
||||
busy,
|
||||
busyInputMode,
|
||||
compacting,
|
||||
clearDraft,
|
||||
disabled,
|
||||
|
|
@ -65,6 +67,7 @@ export function useComposerSubmit({
|
|||
onCancel,
|
||||
onSteer,
|
||||
onSubmit,
|
||||
onToolSteer,
|
||||
queueCurrentDraft,
|
||||
queueEdit,
|
||||
queuedPrompts,
|
||||
|
|
@ -110,6 +113,48 @@ export function useComposerSubmit({
|
|||
[inputDisabled]
|
||||
)
|
||||
|
||||
const queueFallback = (text: string) => {
|
||||
if (activeQueueSessionKey) {
|
||||
enqueueQueuedPrompt(activeQueueSessionKey, { text, attachments: [] })
|
||||
}
|
||||
}
|
||||
|
||||
const redirectDraft = () => {
|
||||
const text = draftRef.current.trim()
|
||||
|
||||
if (!onSteer || !text || attachments.length > 0 || SLASH_COMMAND_RE.test(text)) {
|
||||
queueCurrentDraft()
|
||||
return
|
||||
}
|
||||
|
||||
triggerHaptic('submit')
|
||||
clearDraft()
|
||||
|
||||
void Promise.resolve(onSteer(text)).then(accepted => {
|
||||
if (!accepted) {
|
||||
queueFallback(text)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const toolSteerDraft = () => {
|
||||
const text = draftRef.current.trim()
|
||||
|
||||
if (!onToolSteer || !text || attachments.length > 0 || SLASH_COMMAND_RE.test(text)) {
|
||||
queueCurrentDraft()
|
||||
return
|
||||
}
|
||||
|
||||
triggerHaptic('submit')
|
||||
clearDraft()
|
||||
|
||||
void Promise.resolve(onToolSteer(text)).then(accepted => {
|
||||
if (!accepted) {
|
||||
queueFallback(text)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const submitDraft = () => {
|
||||
if (disabled) {
|
||||
return
|
||||
|
|
@ -140,29 +185,23 @@ export function useComposerSubmit({
|
|||
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.
|
||||
// Slash commands execute immediately even while the agent is busy.
|
||||
if (!attachments.length && SLASH_COMMAND_RE.test(text.trim())) {
|
||||
triggerHaptic('submit')
|
||||
clearDraft()
|
||||
dispatchSubmit(text)
|
||||
} else if (!compacting && !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()
|
||||
if (busyInputMode === 'queue') {
|
||||
queueCurrentDraft()
|
||||
} else if (busyInputMode === 'steer') {
|
||||
toolSteerDraft()
|
||||
} else {
|
||||
redirectDraft()
|
||||
}
|
||||
} else if (payloadPresent) {
|
||||
// Attachments can't ride a redirect (no tool-result image carriage) —
|
||||
// queue the whole payload for the next turn.
|
||||
// Attachments cannot ride either redirect or tool-boundary steering.
|
||||
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())
|
||||
}
|
||||
|
|
@ -180,28 +219,6 @@ export function useComposerSubmit({
|
|||
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
|
||||
|
|
@ -211,5 +228,5 @@ export function useComposerSubmit({
|
|||
focusInput()
|
||||
}
|
||||
|
||||
return { dispatchSubmit, queueDraft, steerDraft, submitDraft }
|
||||
return { dispatchSubmit, queueDraft, redirectDraft, toolSteerDraft, submitDraft }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { ComposerPrimitive } from '@assistant-ui/react'
|
|||
import { useStore } from '@nanostores/react'
|
||||
import { type ClipboardEvent, type FormEvent, type KeyboardEvent, useCallback, useEffect, useRef } from 'react'
|
||||
|
||||
import { useHermesConfigRecord } from '@/app/hooks/use-config-record'
|
||||
import { composerFill, composerSurfaceGlass } from '@/components/chat/composer-dock'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Slot as ContribSlot } from '@/contrib/react/slot'
|
||||
|
|
@ -22,6 +23,7 @@ import { $autoSpeakReplies } from '@/store/voice-prefs'
|
|||
import { useTheme } from '@/themes'
|
||||
|
||||
import { AttachmentList } from './attachments'
|
||||
import { normalizeBusyInputMode, resolveBusyComposerAction } from './busy-input-mode'
|
||||
import { COMPOSER_FADE_BACKGROUND, type QueueEditState, slashArgStage } from './composer-utils'
|
||||
import { ContextMenu } from './context-menu'
|
||||
import { COMPOSER_AREAS, runComposerMiddleware } from './contrib'
|
||||
|
|
@ -104,6 +106,9 @@ export function ChatBar({
|
|||
// Which live composer this instance IS (main | tile) — its attachment set,
|
||||
// focus-bus key, and awaiting-input edge. Main scope = the legacy globals.
|
||||
const scope = useComposerScope()
|
||||
const { data: configRecord } = useHermesConfigRecord()
|
||||
const config = configRecord?.config as { display?: { busy_input_mode?: unknown } } | undefined
|
||||
const busyInputMode = normalizeBusyInputMode(config?.display?.busy_input_mode)
|
||||
const attachments = useStore(scope.attachments.$attachments)
|
||||
const compacting = useStore($compactionActive)
|
||||
const scrolledUp = useStore($threadScrolledUp)
|
||||
|
|
@ -230,25 +235,34 @@ export function ChatBar({
|
|||
const hasComposerPayload = hasText || attachments.length > 0
|
||||
const canSubmit = busy || hasComposerPayload
|
||||
|
||||
// Steer only makes sense mid-turn, text-only (the gateway can't carry images
|
||||
// into a tool result) and never for a slash command (those execute inline).
|
||||
const canSteer = busy && !compacting && !!onSteer && attachments.length === 0 && isSteerableText
|
||||
// Redirect and tool-boundary steering are text-only. Attachments must queue.
|
||||
const canRedirect = !!onSteer && attachments.length === 0 && isSteerableText
|
||||
const canToolSteer = !!gateway && !!sessionId && attachments.length === 0 && isSteerableText
|
||||
const busyAction = resolveBusyComposerAction({
|
||||
busy,
|
||||
canRedirect,
|
||||
canSteer: canToolSteer,
|
||||
compacting,
|
||||
hasPayload: hasComposerPayload,
|
||||
mode: busyInputMode
|
||||
})
|
||||
const onToolSteer = useCallback(async (text: string) => {
|
||||
if (!gateway || !sessionId) {
|
||||
return false
|
||||
}
|
||||
|
||||
// While busy: text redirects the live turn (Cursor-style stop-and-correct),
|
||||
// attachments queue for the next turn, an empty composer stops.
|
||||
const busyAction: 'steer' | 'queue' | 'stop' = canSteer
|
||||
? 'steer'
|
||||
: compacting || hasComposerPayload
|
||||
? 'queue'
|
||||
: 'stop'
|
||||
const result = (await gateway.request('session.steer', { session_id: sessionId, text })) as { status?: string }
|
||||
return result.status === 'queued'
|
||||
}, [gateway, sessionId])
|
||||
|
||||
// The submit engine — the orchestration seam where draft + queue meet. Owns
|
||||
// the submit decision tree, the send-with-restore primitive, and steer.
|
||||
const { queueDraft, steerDraft, submitDraft } = useComposerSubmit({
|
||||
const { queueDraft, submitDraft } = useComposerSubmit({
|
||||
activeQueueSessionKey,
|
||||
activeQueueSessionKeyRef,
|
||||
attachments,
|
||||
busy,
|
||||
busyInputMode,
|
||||
compacting,
|
||||
clearDraft,
|
||||
disabled,
|
||||
|
|
@ -264,6 +278,7 @@ export function ChatBar({
|
|||
onCancel: haltRun,
|
||||
onSteer,
|
||||
onSubmit,
|
||||
onToolSteer,
|
||||
queueCurrentDraft,
|
||||
queueEdit,
|
||||
queuedPrompts,
|
||||
|
|
|
|||
|
|
@ -51,7 +51,10 @@ export interface ChatBarProps {
|
|||
onPickFolders?: () => void
|
||||
onPickImages?: () => void
|
||||
onRemoveAttachment?: (id: string) => void
|
||||
/** Immediate Cursor-style active-turn correction. */
|
||||
onSteer?: (text: string) => Promise<boolean> | boolean
|
||||
/** Tool-boundary steering; does not cancel model generation. */
|
||||
onToolSteer?: (text: string) => Promise<boolean> | boolean
|
||||
onSubmit: (value: string, options?: SubmitTextOptions) => Promise<boolean> | boolean
|
||||
onTranscribeAudio?: (audio: Blob) => Promise<string>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -642,7 +642,13 @@ export const SECTIONS: DesktopConfigSection[] = [
|
|||
id: 'chat',
|
||||
label: 'Chat',
|
||||
icon: MessageCircle,
|
||||
keys: ['display.personality', 'timezone', 'display.show_reasoning', 'agent.image_input_mode']
|
||||
keys: [
|
||||
'display.personality',
|
||||
'timezone',
|
||||
'display.show_reasoning',
|
||||
'display.busy_input_mode',
|
||||
'agent.image_input_mode'
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'appearance',
|
||||
|
|
|
|||
|
|
@ -310,6 +310,17 @@ describe('settings helpers', () => {
|
|||
})
|
||||
|
||||
describe('sectionFieldEntries', () => {
|
||||
it('surfaces busy input mode in Chat Settings as the backend-defined select', () => {
|
||||
const schema = {
|
||||
'display.busy_input_mode': { type: 'select' as const, options: ['interrupt', 'queue', 'steer'] }
|
||||
}
|
||||
const config: HermesConfigRecord = { display: { busy_input_mode: 'interrupt' } }
|
||||
|
||||
const field = new Map(sectionFieldEntries(schema, config).get('chat') ?? []).get('display.busy_input_mode')
|
||||
|
||||
expect(field).toEqual(schema['display.busy_input_mode'])
|
||||
})
|
||||
|
||||
it('renders memory.provider from config even when the backend schema omits it', () => {
|
||||
const schema = { 'memory.memory_enabled': { type: 'boolean' as const } }
|
||||
const config: HermesConfigRecord = { memory: { memory_enabled: true, provider: '' } }
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import { useStore } from '@nanostores/react'
|
||||
import { useMemo, useState } from 'react'
|
||||
|
||||
import { useHermesConfigRecord } from '@/app/hooks/use-config-record'
|
||||
import { normalizeBusyInputMode } from '@/app/chat/composer/busy-input-mode'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
|
||||
import { Kbd, KbdCombo } from '@/components/ui/kbd'
|
||||
|
|
@ -36,6 +38,12 @@ export function KeybindSettings() {
|
|||
const { t } = useI18n()
|
||||
const bindings = useStore($bindings)
|
||||
const k = t.keybinds
|
||||
const { data: configRecord } = useHermesConfigRecord()
|
||||
const config = configRecord?.config as { display?: { busy_input_mode?: unknown } } | undefined
|
||||
const busyInputMode = normalizeBusyInputMode(config?.display?.busy_input_mode)
|
||||
const busyActionId = busyInputMode === 'interrupt' ? 'composer.redirect' : `composer.${busyInputMode}`
|
||||
const readonlyLabel = (shortcut: KeybindReadonly) =>
|
||||
shortcut.id === 'composer.busyAction' ? (k.actions[busyActionId] ?? busyActionId) : (k.actions[shortcut.id] ?? shortcut.id)
|
||||
const [collapsed, setCollapsed] = useState<ReadonlySet<string>>(new Set())
|
||||
// Subscribe so contributed actions appear/disappear live in the map.
|
||||
useContributions(KEYBINDS_AREA)
|
||||
|
|
@ -87,11 +95,11 @@ export function KeybindSettings() {
|
|||
const lower = query.toLowerCase()
|
||||
|
||||
return KEYBIND_READONLY.filter(shortcut => {
|
||||
const label = k.actions[shortcut.id] ?? shortcut.id
|
||||
const label = readonlyLabel(shortcut)
|
||||
|
||||
return label.toLowerCase().includes(lower) || shortcut.id.includes(lower)
|
||||
})
|
||||
}, [isSearching, query, k.actions])
|
||||
}, [isSearching, query, k.actions, busyActionId])
|
||||
|
||||
return (
|
||||
<SettingsContent>
|
||||
|
|
@ -132,7 +140,7 @@ export function KeybindSettings() {
|
|||
<KeybindRow action={action} key={action.id} />
|
||||
))}
|
||||
{filteredReadonly?.map(shortcut => (
|
||||
<ReadonlyRow key={shortcut.id} shortcut={shortcut} />
|
||||
<ReadonlyRow key={shortcut.id} label={readonlyLabel(shortcut)} shortcut={shortcut} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
|
@ -160,7 +168,7 @@ export function KeybindSettings() {
|
|||
open={sectionOpen}
|
||||
/>
|
||||
{sectionOpen && actions.map(action => <KeybindRow action={action} key={action.id} />)}
|
||||
{sectionOpen && readonly.map(shortcut => <ReadonlyRow key={shortcut.id} shortcut={shortcut} />)}
|
||||
{sectionOpen && readonly.map(shortcut => <ReadonlyRow key={shortcut.id} label={readonlyLabel(shortcut)} shortcut={shortcut} />)}
|
||||
</section>
|
||||
)
|
||||
})}
|
||||
|
|
@ -255,10 +263,7 @@ function KeybindRow({ action }: { action: KeybindActionMeta }) {
|
|||
|
||||
// Fixed shortcut: same layout as KeybindRow but the caps aren't interactive and
|
||||
// the trailing reset slot stays empty (spacer keeps the columns aligned).
|
||||
function ReadonlyRow({ shortcut }: { shortcut: KeybindReadonly }) {
|
||||
const { t } = useI18n()
|
||||
const k = t.keybinds
|
||||
const label = k.actions[shortcut.id] ?? shortcut.id
|
||||
function ReadonlyRow({ label, shortcut }: { label: string; shortcut: KeybindReadonly }) {
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2.5 rounded-lg px-2.5 py-1">
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { Tooltip as TooltipPrimitive } from 'radix-ui'
|
|||
import * as React from 'react'
|
||||
|
||||
import { useI18n } from '@/i18n'
|
||||
import { formatCombo } from '@/lib/keybinds/combo'
|
||||
import { useKeybindHint } from '@/lib/keybinds/use-keybind-hint'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
|
|
@ -147,6 +148,8 @@ function TipHintLabel({ text, hint }: TipHintLabelProps) {
|
|||
interface TipKeybindLabelProps {
|
||||
/** Keybind action id — pulls the label from i18n AND the combo from the store. */
|
||||
actionId: string
|
||||
/** A context-specific fixed combo, used when one action has multiple routes. */
|
||||
combo?: string
|
||||
/** Override the i18n label (for context-dependent text like "Show"/"Hide"). */
|
||||
text?: string
|
||||
}
|
||||
|
|
@ -154,9 +157,10 @@ interface TipKeybindLabelProps {
|
|||
/** TipHintLabel that auto-reads both its label and keybind from the action
|
||||
* registry. Pass only `actionId` for the common case; pass `text` to override
|
||||
* when the button's tooltip is context-dependent. */
|
||||
function TipKeybindLabel({ actionId, text }: TipKeybindLabelProps) {
|
||||
function TipKeybindLabel({ actionId, combo, text }: TipKeybindLabelProps) {
|
||||
const { t } = useI18n()
|
||||
const hint = useKeybindHint(actionId)
|
||||
const boundHint = useKeybindHint(actionId)
|
||||
const hint = combo ? formatCombo(combo) : boundHint
|
||||
|
||||
const label = text ?? t.keybinds.actions[actionId] ?? actionId
|
||||
|
||||
|
|
|
|||
|
|
@ -291,6 +291,8 @@ export const en: Translations = {
|
|||
'profile.create': 'Create profile',
|
||||
'composer.send': 'Send message',
|
||||
'composer.newline': 'Insert newline',
|
||||
'composer.busyAction': 'Run configured busy-input action',
|
||||
'composer.redirect': 'Redirect the running turn',
|
||||
'composer.steer': 'Steer the running turn',
|
||||
'composer.queue': 'Queue message',
|
||||
'composer.sendQueued': 'Send next queued turn',
|
||||
|
|
@ -1796,6 +1798,7 @@ export const en: Translations = {
|
|||
],
|
||||
startVoice: 'Start voice conversation',
|
||||
queueMessage: 'Queue message',
|
||||
redirect: 'Redirect the current run',
|
||||
steer: 'Steer the current run',
|
||||
stop: 'Stop',
|
||||
send: 'Send',
|
||||
|
|
|
|||
|
|
@ -1496,6 +1496,7 @@ export interface Translations {
|
|||
followUpPlaceholders: readonly string[]
|
||||
startVoice: string
|
||||
queueMessage: string
|
||||
redirect: string
|
||||
steer: string
|
||||
stop: string
|
||||
send: string
|
||||
|
|
|
|||
|
|
@ -282,7 +282,9 @@ export const zh: Translations = {
|
|||
'profile.create': '创建配置',
|
||||
'composer.send': '发送消息',
|
||||
'composer.newline': '插入换行',
|
||||
'composer.steer': '引导正在运行的回合',
|
||||
'composer.busyAction': '执行配置的忙碌输入操作',
|
||||
'composer.redirect': '重定向当前运行',
|
||||
'composer.steer': '引导当前运行',
|
||||
'composer.queue': '消息排队',
|
||||
'composer.sendQueued': '发送下一条排队消息',
|
||||
'composer.mention': '引用文件、文件夹、网址',
|
||||
|
|
@ -1985,6 +1987,7 @@ export const zh: Translations = {
|
|||
],
|
||||
startVoice: '开始语音对话',
|
||||
queueMessage: '排队消息',
|
||||
redirect: '重定向当前运行',
|
||||
steer: '引导当前运行',
|
||||
stop: '停止',
|
||||
send: '发送',
|
||||
|
|
|
|||
|
|
@ -197,7 +197,7 @@ export interface KeybindReadonly {
|
|||
export const KEYBIND_READONLY: readonly KeybindReadonly[] = [
|
||||
{ id: 'composer.send', category: 'composer', keys: ['enter'] },
|
||||
{ id: 'composer.newline', category: 'composer', keys: ['shift+enter'] },
|
||||
{ id: 'composer.steer', category: 'composer', keys: ['enter'] },
|
||||
{ id: 'composer.busyAction', category: 'composer', keys: ['enter'] },
|
||||
{ id: 'composer.queue', category: 'composer', keys: ['mod+enter'] },
|
||||
{ id: 'composer.sendQueued', category: 'composer', keys: ['mod+shift+k'] },
|
||||
{ id: 'composer.mention', category: 'composer', keys: ['@'] },
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue