mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-27 11:22:03 +00:00
fix(desktop): treat a pending prompt as paused-on-you, not working
A clarify/approval/sudo/secret prompt blocks the turn on the user, but the UI treated it as an in-flight turn: the "thinking" timer kept ticking and Esc interrupted the run — discarding a question you might want to come back to. Add $activeSessionAwaitingInput (the pet's awaitingInput concept, scoped to the active session) and use it to suppress the stall indicator and disarm Esc while a prompt waits. Clear the session's prompts (and needsInput) on Stop and on turn end so a resolved/aborted turn can't leave a dead panel or a stuck "needs input" dot.
This commit is contained in:
parent
8559246bfb
commit
54b50037e1
6 changed files with 69 additions and 5 deletions
|
|
@ -63,6 +63,7 @@ import { $statusItemsBySession } from '@/store/composer-status'
|
|||
import { notify } from '@/store/notifications'
|
||||
import { $previewStatusBySession } from '@/store/preview-status'
|
||||
import { listRepoBranches, requestStartWorkSession, startWorkInRepo, switchBranchInRepo } from '@/store/projects'
|
||||
import { $activeSessionAwaitingInput } from '@/store/prompts'
|
||||
import { toggleReview } from '@/store/review'
|
||||
import { $gatewayState, $messages, setSessionPickerOpen } from '@/store/session'
|
||||
import { $threadScrolledUp } from '@/store/thread-scroll'
|
||||
|
|
@ -229,6 +230,11 @@ export function ChatBar({
|
|||
const statusItemsBySession = useStore($statusItemsBySession)
|
||||
const previewStatusBySession = useStore($previewStatusBySession)
|
||||
const scrolledUp = useStore($threadScrolledUp)
|
||||
// The turn is parked on the user (clarify / approval / sudo / secret). Esc must
|
||||
// not interrupt it — there's nothing actively running to stop, and stopping
|
||||
// would discard a question the user may want to come back to. The blocking
|
||||
// prompt owns its own dismissal (Skip, Reject, dialog close).
|
||||
const awaitingInput = useStore($activeSessionAwaitingInput)
|
||||
// Pop-out is a shared, persisted state — but secondary windows (the Ctrl+Shift+N
|
||||
// tiny window, subagent watch windows) always start docked and can't pop out:
|
||||
// a floating composer makes no sense in a single-session side window, and it
|
||||
|
|
@ -1214,8 +1220,10 @@ export function ChatBar({
|
|||
return
|
||||
}
|
||||
|
||||
// Otherwise Esc interrupts the running turn (Stop-button parity).
|
||||
if (busy) {
|
||||
// Otherwise Esc interrupts the running turn (Stop-button parity) — unless
|
||||
// the turn is parked waiting on the user, where Esc must not discard the
|
||||
// pending prompt.
|
||||
if (busy && !awaitingInput) {
|
||||
event.preventDefault()
|
||||
triggerHaptic('cancel')
|
||||
void Promise.resolve(onCancel())
|
||||
|
|
@ -1779,7 +1787,10 @@ export function ChatBar({
|
|||
const escCancelRef = useRef<(event: globalThis.KeyboardEvent) => void>(() => {})
|
||||
|
||||
escCancelRef.current = (event: globalThis.KeyboardEvent) => {
|
||||
if (event.key !== 'Escape' || event.defaultPrevented || !busy) {
|
||||
// `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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ import {
|
|||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { isProviderSetupErrorMessage } from '@/lib/provider-setup-errors'
|
||||
import { parseTodos } from '@/lib/todos'
|
||||
import { setClarifyRequest } from '@/store/clarify'
|
||||
import { clearClarifyRequest, setClarifyRequest } from '@/store/clarify'
|
||||
import { setSessionCompacting } from '@/store/compaction'
|
||||
import { refreshBackgroundProcesses } from '@/store/composer-status'
|
||||
import { $gateway } from '@/store/gateway'
|
||||
|
|
@ -913,6 +913,7 @@ export function useMessageStream({
|
|||
// session so a background turn finishing can't wipe the active chat's
|
||||
// prompt, and vice versa.
|
||||
clearAllPrompts(sessionId)
|
||||
clearClarifyRequest(undefined, sessionId)
|
||||
setSessionCompacting(sessionId, false)
|
||||
|
||||
flushQueuedDeltas(sessionId)
|
||||
|
|
@ -1185,6 +1186,7 @@ export function useMessageStream({
|
|||
// the failed turn (same intent as the message.complete clear).
|
||||
if (sessionId) {
|
||||
clearAllPrompts(sessionId)
|
||||
clearClarifyRequest(undefined, sessionId)
|
||||
setSessionCompacting(sessionId, false)
|
||||
compactedTurnRef.current.delete(sessionId)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import { triggerHaptic } from '@/lib/haptics'
|
|||
import { setMutableRef } from '@/lib/mutable-ref'
|
||||
import { isProviderSetupErrorMessage } from '@/lib/provider-setup-errors'
|
||||
import { setSessionYolo } from '@/lib/yolo-session'
|
||||
import { clearClarifyRequest } from '@/store/clarify'
|
||||
import { openCommandPalettePage } from '@/store/command-palette'
|
||||
import {
|
||||
$composerAttachments,
|
||||
|
|
@ -44,6 +45,7 @@ import { setPetScale } from '@/store/pet-gallery'
|
|||
import { $petGenInput, openPetGenerate } from '@/store/pet-generate'
|
||||
import { clearPreviewArtifacts } from '@/store/preview-status'
|
||||
import { $activeGatewayProfile, $newChatProfile, ensureGatewayProfile, normalizeProfileKey } from '@/store/profile'
|
||||
import { clearAllPrompts } from '@/store/prompts'
|
||||
import {
|
||||
$busy,
|
||||
$connection,
|
||||
|
|
@ -1535,6 +1537,7 @@ export function usePromptActions({
|
|||
awaitingResponse: false,
|
||||
streamId: null,
|
||||
pendingBranchGroup: null,
|
||||
needsInput: false,
|
||||
interrupted: true
|
||||
}
|
||||
})
|
||||
|
|
@ -1542,6 +1545,12 @@ export function usePromptActions({
|
|||
clearSessionTodos(sessionId)
|
||||
clearSessionSubagents(sessionId)
|
||||
resetSessionBackground(sessionId)
|
||||
// Stop ends the turn, so the gateway is no longer blocked on any prompt it
|
||||
// raised. Drop this session's pending clarify / approval / sudo / secret so
|
||||
// a dead panel (and the sidebar "needs input" dot) can't linger and accept
|
||||
// an answer the backend will reject.
|
||||
clearAllPrompts(sessionId)
|
||||
clearClarifyRequest(undefined, sessionId)
|
||||
|
||||
try {
|
||||
await requestGateway('session.interrupt', { session_id: sessionId })
|
||||
|
|
|
|||
|
|
@ -100,6 +100,7 @@ import { $backgroundResume } from '@/store/background-delegation'
|
|||
import { $compactionActive } from '@/store/compaction'
|
||||
import type { ComposerAttachment } from '@/store/composer'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
import { $activeSessionAwaitingInput } from '@/store/prompts'
|
||||
import { $connection } from '@/store/session'
|
||||
import { notifyThreadEditClose, notifyThreadEditOpen } from '@/store/thread-scroll'
|
||||
import { $voicePlayback } from '@/store/voice-playback'
|
||||
|
|
@ -485,6 +486,10 @@ const StreamStallIndicator: FC = () => {
|
|||
|
||||
const [stalled, setStalled] = useState(false)
|
||||
const compacting = useStore($compactionActive)
|
||||
// A pending clarify / approval / sudo / secret means the turn is paused on the
|
||||
// user, not working — so don't resurrect the "thinking" timer while they
|
||||
// decide (matches the pet's awaitingInput pose taking priority over busy).
|
||||
const awaitingInput = useStore($activeSessionAwaitingInput)
|
||||
|
||||
useEffect(() => {
|
||||
setStalled(false)
|
||||
|
|
@ -493,7 +498,7 @@ const StreamStallIndicator: FC = () => {
|
|||
return () => window.clearTimeout(id)
|
||||
}, [activity])
|
||||
|
||||
const active = stalled || compacting
|
||||
const active = (stalled || compacting) && !awaitingInput
|
||||
const elapsed = useElapsedSeconds(active)
|
||||
|
||||
if (!active) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { clearClarifyRequest, setClarifyRequest } from './clarify'
|
||||
import {
|
||||
$activeSessionAwaitingInput,
|
||||
$approvalRequest,
|
||||
$secretRequest,
|
||||
$sudoRequest,
|
||||
|
|
@ -22,6 +24,7 @@ beforeEach(() => {
|
|||
|
||||
afterEach(() => {
|
||||
clearAllPrompts()
|
||||
clearClarifyRequest()
|
||||
$activeSessionId.set(null)
|
||||
})
|
||||
|
||||
|
|
@ -130,3 +133,26 @@ describe('clearAllPrompts', () => {
|
|||
expect($approvalRequest.get()?.command).toBe('y')
|
||||
})
|
||||
})
|
||||
|
||||
describe('$activeSessionAwaitingInput', () => {
|
||||
it('is true while any blocking prompt (clarify or approval/sudo/secret) is parked on the active session', () => {
|
||||
expect($activeSessionAwaitingInput.get()).toBe(false)
|
||||
|
||||
setApprovalRequest({ command: 'x', description: 'd', sessionId: 's1' })
|
||||
expect($activeSessionAwaitingInput.get()).toBe(true)
|
||||
|
||||
clearApprovalRequest('s1')
|
||||
expect($activeSessionAwaitingInput.get()).toBe(false)
|
||||
|
||||
setClarifyRequest({ choices: null, question: 'q', requestId: 'c1', sessionId: 's1' })
|
||||
expect($activeSessionAwaitingInput.get()).toBe(true)
|
||||
})
|
||||
|
||||
it('ignores a prompt parked on a background session', () => {
|
||||
setSudoRequest({ requestId: 'r', sessionId: 's2' })
|
||||
expect($activeSessionAwaitingInput.get()).toBe(false)
|
||||
|
||||
$activeSessionId.set('s2')
|
||||
expect($activeSessionAwaitingInput.get()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { atom, computed, type ReadableAtom } from 'nanostores'
|
||||
|
||||
import { $clarifyRequest } from './clarify'
|
||||
import { $activeSessionId } from './session'
|
||||
|
||||
// Blocking interactive prompts the gateway raises mid-turn. Each maps to a
|
||||
|
|
@ -110,6 +111,16 @@ export const $secretRequest = secret.$active
|
|||
export const setSecretRequest = secret.set
|
||||
export const clearSecretRequest = secret.clear
|
||||
|
||||
// True when the active session is blocked on the user (clarify question or an
|
||||
// approval / sudo / secret prompt). Mirrors the pet's `awaitingInput` concept
|
||||
// (agent/pet/state.py): the turn is paused on you, not working — so callers can
|
||||
// suppress "thinking" indicators and the Esc-to-interrupt shortcut while you
|
||||
// decide, instead of treating the wait as an in-flight turn.
|
||||
export const $activeSessionAwaitingInput = computed(
|
||||
[$clarifyRequest, $approvalRequest, $sudoRequest, $secretRequest],
|
||||
(clarify, approval, sudo, secret) => Boolean(clarify || approval || sudo || secret)
|
||||
)
|
||||
|
||||
// Drop in-flight prompts for `sessionId` (a turn ended) across all three kinds —
|
||||
// or every parked prompt when no session is given (global reset / tests).
|
||||
export function clearAllPrompts(sessionId?: string | null): void {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue