mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-12 08:51:53 +00:00
feat(desktop): steer the live run from the composer
The desktop app could only queue while busy — `/steer` was in the palette but had no first-class affordance, so the "nudge the agent mid-turn without interrupting" lane was effectively unreachable. Add a steer action to the composer: while busy with a text-only draft, a steering-wheel button (and Cmd/Ctrl+Enter) injects the text into the live turn via the `session.steer` RPC — the gateway folds it into the next tool result so the model reads it on its next iteration. Plain Enter still queues. steerPrompt returns false when the gateway has no live tool window (or the RPC errors), and the composer re-queues the words so nothing is lost — the same safety net as a plain queue.
This commit is contained in:
parent
e375c33f70
commit
40aef6af91
12 changed files with 187 additions and 8 deletions
|
|
@ -3,7 +3,7 @@ import { Codicon } from '@/components/ui/codicon'
|
|||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { AudioLines, Layers3, Loader2, Square } from '@/lib/icons'
|
||||
import { AudioLines, Layers3, Loader2, Square, SteeringWheel } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import type { ConversationStatus } from './hooks/use-voice-conversation'
|
||||
|
|
@ -38,16 +38,19 @@ interface ConversationProps {
|
|||
export function ComposerControls({
|
||||
busy,
|
||||
busyAction,
|
||||
canSteer,
|
||||
canSubmit,
|
||||
conversation,
|
||||
disabled,
|
||||
hasComposerPayload,
|
||||
state,
|
||||
voiceStatus,
|
||||
onDictate
|
||||
onDictate,
|
||||
onSteer
|
||||
}: {
|
||||
busy: boolean
|
||||
busyAction: 'queue' | 'stop'
|
||||
canSteer: boolean
|
||||
canSubmit: boolean
|
||||
conversation: ConversationProps
|
||||
disabled: boolean
|
||||
|
|
@ -55,6 +58,7 @@ export function ComposerControls({
|
|||
state: ChatBarState
|
||||
voiceStatus: VoiceStatus
|
||||
onDictate: () => void
|
||||
onSteer: () => void
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const c = t.composer
|
||||
|
|
@ -68,6 +72,21 @@ export function ComposerControls({
|
|||
return (
|
||||
<div className="ml-auto flex shrink-0 items-center gap-(--composer-control-gap)">
|
||||
<DictationButton disabled={disabled} onToggle={onDictate} state={state.voice} status={voiceStatus} />
|
||||
{canSteer && (
|
||||
<Tip label={c.steer}>
|
||||
<Button
|
||||
aria-label={c.steer}
|
||||
className={GHOST_ICON_BTN}
|
||||
disabled={disabled}
|
||||
onClick={onSteer}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<SteeringWheel size={16} />
|
||||
</Button>
|
||||
</Tip>
|
||||
)}
|
||||
{showVoicePrimary ? (
|
||||
<Tip label={c.startVoice}>
|
||||
<Button
|
||||
|
|
|
|||
|
|
@ -123,6 +123,7 @@ export function ChatBar({
|
|||
onPickFolders,
|
||||
onPickImages,
|
||||
onRemoveAttachment,
|
||||
onSteer,
|
||||
onSubmit,
|
||||
onTranscribeAudio
|
||||
}: ChatBarProps) {
|
||||
|
|
@ -169,6 +170,10 @@ export function ChatBar({
|
|||
const canSubmit = busy || hasComposerPayload
|
||||
const editingQueuedPrompt = queueEdit ? (queuedPrompts.find(entry => entry.id === queueEdit.entryId) ?? null) : null
|
||||
const busyAction = busy && hasComposerPayload ? 'queue' : 'stop'
|
||||
// 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 && !!onSteer && attachments.length === 0 && draft.trim().length > 0 && !SLASH_COMMAND_RE.test(draft.trim())
|
||||
const showHelpHint = draft === '?'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
|
@ -792,6 +797,15 @@ export function ChatBar({
|
|||
return
|
||||
}
|
||||
|
||||
// Cmd/Ctrl+Enter steers the draft into the live run (nudge, no interrupt);
|
||||
// plain Enter still queues while busy.
|
||||
if (event.key === 'Enter' && (event.metaKey || event.ctrlKey) && !event.shiftKey && canSteer) {
|
||||
event.preventDefault()
|
||||
steerDraft()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault()
|
||||
|
||||
|
|
@ -1070,6 +1084,26 @@ export function ChatBar({
|
|||
return true
|
||||
}, [activeQueueSessionKey, attachments, clearDraft, draft])
|
||||
|
||||
// Steer the live turn (nudge without interrupting). Clears the draft up front
|
||||
// for snappy feedback; if the gateway rejects (no live tool window) the words
|
||||
// are re-queued so nothing is lost — same safety net as a plain queue.
|
||||
const steerDraft = useCallback(() => {
|
||||
if (!onSteer || !canSteer) {
|
||||
return
|
||||
}
|
||||
|
||||
const text = draftRef.current.trim()
|
||||
|
||||
triggerHaptic('submit')
|
||||
clearDraft()
|
||||
|
||||
void Promise.resolve(onSteer(text)).then(accepted => {
|
||||
if (!accepted && activeQueueSessionKey) {
|
||||
enqueueQueuedPrompt(activeQueueSessionKey, { text, attachments: [] })
|
||||
}
|
||||
})
|
||||
}, [activeQueueSessionKey, canSteer, clearDraft, onSteer])
|
||||
|
||||
// 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(
|
||||
|
|
@ -1305,6 +1339,7 @@ export function ChatBar({
|
|||
<ComposerControls
|
||||
busy={busy}
|
||||
busyAction={busyAction}
|
||||
canSteer={canSteer}
|
||||
canSubmit={canSubmit}
|
||||
conversation={{
|
||||
active: voiceConversationActive,
|
||||
|
|
@ -1322,6 +1357,7 @@ export function ChatBar({
|
|||
disabled={disabled}
|
||||
hasComposerPayload={hasComposerPayload}
|
||||
onDictate={dictate}
|
||||
onSteer={steerDraft}
|
||||
state={state}
|
||||
voiceStatus={voiceStatus}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ export interface ChatBarProps {
|
|||
onPickFolders?: () => void
|
||||
onPickImages?: () => void
|
||||
onRemoveAttachment?: (id: string) => void
|
||||
onSteer?: (text: string) => Promise<boolean> | boolean
|
||||
onSubmit: (
|
||||
value: string,
|
||||
options?: { attachments?: ComposerAttachment[]; fromQueue?: boolean }
|
||||
|
|
|
|||
|
|
@ -72,6 +72,7 @@ interface ChatViewProps extends Omit<React.ComponentProps<'div'>, 'onSubmit'> {
|
|||
onPickFolders: () => void
|
||||
onPickImages: () => void
|
||||
onRemoveAttachment: (id: string) => void
|
||||
onSteer: (text: string) => Promise<boolean> | boolean
|
||||
onSubmit: (
|
||||
text: string,
|
||||
options?: { attachments?: ComposerAttachment[]; fromQueue?: boolean }
|
||||
|
|
@ -164,6 +165,7 @@ export function ChatView({
|
|||
onPickFolders,
|
||||
onPickImages,
|
||||
onRemoveAttachment,
|
||||
onSteer,
|
||||
onSubmit,
|
||||
onThreadMessagesChange,
|
||||
onEdit,
|
||||
|
|
@ -370,6 +372,7 @@ export function ChatView({
|
|||
onPickFolders={onPickFolders}
|
||||
onPickImages={onPickImages}
|
||||
onRemoveAttachment={onRemoveAttachment}
|
||||
onSteer={onSteer}
|
||||
onSubmit={onSubmit}
|
||||
onTranscribeAudio={onTranscribeAudio}
|
||||
queueSessionKey={selectedSessionId || activeSessionId}
|
||||
|
|
|
|||
|
|
@ -569,8 +569,15 @@ export function DesktopController() {
|
|||
|
||||
const handleSkinCommand = useSkinCommand()
|
||||
|
||||
const { cancelRun, editMessage, handleThreadMessagesChange, reloadFromMessage, submitText, transcribeVoiceAudio } =
|
||||
usePromptActions({
|
||||
const {
|
||||
cancelRun,
|
||||
editMessage,
|
||||
handleThreadMessagesChange,
|
||||
reloadFromMessage,
|
||||
steerPrompt,
|
||||
submitText,
|
||||
transcribeVoiceAudio
|
||||
} = usePromptActions({
|
||||
activeSessionId,
|
||||
activeSessionIdRef,
|
||||
branchCurrentSession: branchInNewChat,
|
||||
|
|
@ -748,6 +755,7 @@ export function DesktopController() {
|
|||
onPickImages={() => void composer.pickImages()}
|
||||
onReload={reloadFromMessage}
|
||||
onRemoveAttachment={id => void composer.removeAttachment(id)}
|
||||
onSteer={steerPrompt}
|
||||
onSubmit={submitText}
|
||||
onThreadMessagesChange={handleThreadMessagesChange}
|
||||
onToggleSelectedPin={toggleSelectedPin}
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ function sessionInfo(overrides: Partial<SessionInfo> = {}): SessionInfo {
|
|||
}
|
||||
|
||||
interface HarnessHandle {
|
||||
steerPrompt: (text: string) => Promise<boolean>
|
||||
submitText: (text: string, options?: { attachments?: never[]; fromQueue?: boolean }) => Promise<boolean>
|
||||
}
|
||||
|
||||
|
|
@ -88,8 +89,8 @@ function Harness({
|
|||
})
|
||||
|
||||
useEffect(() => {
|
||||
onReady({ submitText: actions.submitText })
|
||||
}, [actions.submitText, onReady])
|
||||
onReady({ steerPrompt: actions.steerPrompt, submitText: actions.submitText })
|
||||
}, [actions.steerPrompt, actions.submitText, onReady])
|
||||
|
||||
return null
|
||||
}
|
||||
|
|
@ -259,3 +260,57 @@ describe('usePromptActions submit / queue drain semantics', () => {
|
|||
expect(requestGateway).not.toHaveBeenCalledWith('prompt.submit', expect.anything())
|
||||
})
|
||||
})
|
||||
|
||||
describe('usePromptActions steerPrompt', () => {
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('injects the trimmed text via session.steer and reports acceptance on a queued status', async () => {
|
||||
const requestGateway = vi.fn(async () => ({ status: 'queued' }) as never)
|
||||
|
||||
let handle: HarnessHandle | null = null
|
||||
render(<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />)
|
||||
|
||||
const accepted = await handle!.steerPrompt(' nudge the run ')
|
||||
|
||||
expect(accepted).toBe(true)
|
||||
// Steer never starts a turn — it rides the live run via session.steer only.
|
||||
expect(requestGateway).toHaveBeenCalledWith('session.steer', {
|
||||
session_id: RUNTIME_SESSION_ID,
|
||||
text: 'nudge the run'
|
||||
})
|
||||
expect(requestGateway).not.toHaveBeenCalledWith('prompt.submit', expect.anything())
|
||||
})
|
||||
|
||||
it('reports rejection (so the caller queues) when the gateway has no live tool window', async () => {
|
||||
const requestGateway = vi.fn(async () => ({ status: 'rejected' }) as never)
|
||||
|
||||
let handle: HarnessHandle | null = null
|
||||
render(<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />)
|
||||
|
||||
expect(await handle!.steerPrompt('too late')).toBe(false)
|
||||
})
|
||||
|
||||
it('reports rejection (never throws) when the steer RPC errors', async () => {
|
||||
const requestGateway = vi.fn(async () => {
|
||||
throw new Error('agent does not support steer')
|
||||
})
|
||||
|
||||
let handle: HarnessHandle | null = null
|
||||
render(<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />)
|
||||
|
||||
expect(await handle!.steerPrompt('boom')).toBe(false)
|
||||
})
|
||||
|
||||
it('skips the RPC entirely for empty text', async () => {
|
||||
const requestGateway = vi.fn(async () => ({ status: 'queued' }) as never)
|
||||
|
||||
let handle: HarnessHandle | null = null
|
||||
render(<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />)
|
||||
|
||||
expect(await handle!.steerPrompt(' ')).toBe(false)
|
||||
expect(requestGateway).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -41,7 +41,13 @@ import {
|
|||
setYoloActive
|
||||
} from '@/store/session'
|
||||
|
||||
import type { ClientSessionState, ImageAttachResponse, SessionTitleResponse, SlashExecResponse } from '../../types'
|
||||
import type {
|
||||
ClientSessionState,
|
||||
ImageAttachResponse,
|
||||
SessionSteerResponse,
|
||||
SessionTitleResponse,
|
||||
SlashExecResponse
|
||||
} from '../../types'
|
||||
|
||||
function blobToDataUrl(blob: Blob): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
|
|
@ -743,6 +749,37 @@ export function usePromptActions({
|
|||
}
|
||||
}, [activeSessionId, activeSessionIdRef, busyRef, requestGateway, updateSessionState])
|
||||
|
||||
// Steer = nudge the live turn without interrupting: the gateway appends the
|
||||
// text to the next tool result so the model reads it on its next iteration
|
||||
// (desktop parity with `/steer`). Returns false on reject (no live tool
|
||||
// window) so the caller can fall back to queueing the words for the next turn.
|
||||
const steerPrompt = useCallback(
|
||||
async (rawText: string): Promise<boolean> => {
|
||||
const text = rawText.trim()
|
||||
const sessionId = activeSessionId || activeSessionIdRef.current
|
||||
|
||||
if (!text || !sessionId) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await requestGateway<SessionSteerResponse>('session.steer', { session_id: sessionId, text })
|
||||
|
||||
if (result?.status === 'queued') {
|
||||
triggerHaptic('submit')
|
||||
notify({ kind: 'success', title: 'Steered', message: text })
|
||||
|
||||
return true
|
||||
}
|
||||
} catch {
|
||||
// Swallow — caller queues the text so nothing is lost.
|
||||
}
|
||||
|
||||
return false
|
||||
},
|
||||
[activeSessionId, activeSessionIdRef, requestGateway]
|
||||
)
|
||||
|
||||
const reloadFromMessage = useCallback(
|
||||
async (parentId: string | null) => {
|
||||
if (!activeSessionId || $busy.get()) {
|
||||
|
|
@ -926,5 +963,13 @@ export function usePromptActions({
|
|||
[activeSessionIdRef, updateSessionState]
|
||||
)
|
||||
|
||||
return { cancelRun, editMessage, handleThreadMessagesChange, reloadFromMessage, submitText, transcribeVoiceAudio }
|
||||
return {
|
||||
cancelRun,
|
||||
editMessage,
|
||||
handleThreadMessagesChange,
|
||||
reloadFromMessage,
|
||||
steerPrompt,
|
||||
submitText,
|
||||
transcribeVoiceAudio
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,13 @@ export interface SlashExecResponse {
|
|||
warning?: string
|
||||
}
|
||||
|
||||
export interface SessionSteerResponse {
|
||||
// 'queued' == accepted into the live turn's steer slot (injected at the next
|
||||
// tool-result boundary); 'rejected' == no live tool window, caller queues.
|
||||
status?: 'queued' | 'rejected'
|
||||
text?: string
|
||||
}
|
||||
|
||||
export interface SessionTitleResponse {
|
||||
title?: string
|
||||
// True when the session row isn't persisted yet and the title was queued
|
||||
|
|
|
|||
|
|
@ -650,6 +650,7 @@ export const en: Translations = {
|
|||
],
|
||||
startVoice: 'Start voice conversation',
|
||||
queueMessage: 'Queue message',
|
||||
steer: 'Steer the current run (⌘⏎)',
|
||||
stop: 'Stop',
|
||||
send: 'Send',
|
||||
speaking: 'Speaking',
|
||||
|
|
|
|||
|
|
@ -548,6 +548,7 @@ export interface Translations {
|
|||
followUpPlaceholders: readonly string[]
|
||||
startVoice: string
|
||||
queueMessage: string
|
||||
steer: string
|
||||
stop: string
|
||||
send: string
|
||||
speaking: string
|
||||
|
|
|
|||
|
|
@ -779,6 +779,7 @@ export const zh: Translations = {
|
|||
],
|
||||
startVoice: '开始语音对话',
|
||||
queueMessage: '排队消息',
|
||||
steer: '引导当前运行 (⌘⏎)',
|
||||
stop: '停止',
|
||||
send: '发送',
|
||||
speaking: '讲话中',
|
||||
|
|
|
|||
|
|
@ -83,6 +83,7 @@ import {
|
|||
IconAdjustmentsHorizontal as SlidersHorizontal,
|
||||
IconSparkles as Sparkles,
|
||||
IconSquare as Square,
|
||||
IconSteeringWheel as SteeringWheel,
|
||||
IconSun as Sun,
|
||||
IconTerminal2 as Terminal,
|
||||
IconTrash as Trash2,
|
||||
|
|
@ -183,6 +184,7 @@ export {
|
|||
SlidersHorizontal,
|
||||
Sparkles,
|
||||
Square,
|
||||
SteeringWheel,
|
||||
Sun,
|
||||
Terminal,
|
||||
Trash2,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue