feat(desktop): running is not busy
Gate composer submit and plugin host busy on the target session slice, not a leftover foreground busyRef. Staff can keep typing while a worker session is running. Includes the follow-up test that submit uses the target session busy flag.
This commit is contained in:
@@ -66,13 +66,19 @@ describe('ComposerControls shortcut tooltips', () => {
|
||||
await expectShortcutTooltip('Send', '↵')
|
||||
})
|
||||
|
||||
it('shows Enter for Steer', async () => {
|
||||
it('keeps Send (not Steer) while a turn is running if there is a payload', async () => {
|
||||
renderControls({ busy: true, busyAction: 'steer' })
|
||||
|
||||
await expectShortcutTooltip('Steer the current run', '↵')
|
||||
await expectShortcutTooltip('Send', '↵')
|
||||
})
|
||||
|
||||
it('shows Ctrl+Enter for Queue', async () => {
|
||||
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+↵')
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
Layers3,
|
||||
Loader2,
|
||||
Square,
|
||||
SteeringWheel,
|
||||
Volume2,
|
||||
VolumeX
|
||||
} from '@/lib/icons'
|
||||
@@ -87,7 +86,10 @@ export function ComposerControls({
|
||||
}
|
||||
|
||||
const showVoicePrimary = !busy && !hasComposerPayload
|
||||
const busyLabel = busyAction === 'queue' ? c.queueMessage : busyAction === 'steer' ? c.steer : c.stop
|
||||
// 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
|
||||
|
||||
return (
|
||||
<div className="ml-auto flex shrink-0 items-center gap-(--composer-control-gap)">
|
||||
@@ -95,7 +97,7 @@ export function ComposerControls({
|
||||
<DictationButton disabled={disabled} onToggle={onDictate} state={state.voice} status={voiceStatus} />
|
||||
<AutoSpeakButton active={autoSpeak} disabled={disabled} onToggle={onToggleAutoSpeak} />
|
||||
<WakeWordButton disabled={disabled} />
|
||||
{busyAction === 'steer' ? (
|
||||
{showQueueButton ? (
|
||||
<Tip label={<TipKeybindLabel actionId="composer.queue" text={c.queueMessage} />}>
|
||||
<Button
|
||||
aria-label={c.queueMessage}
|
||||
@@ -129,36 +131,21 @@ export function ComposerControls({
|
||||
) : (
|
||||
<Tip
|
||||
label={
|
||||
busy ? (
|
||||
<TipKeybindLabel
|
||||
actionId={
|
||||
busyAction === 'steer'
|
||||
? 'composer.steer'
|
||||
: busyAction === 'queue'
|
||||
? 'composer.queue'
|
||||
: 'composer.send'
|
||||
}
|
||||
text={busyLabel}
|
||||
/>
|
||||
showStop ? (
|
||||
<TipKeybindLabel actionId="composer.send" text={c.stop} />
|
||||
) : (
|
||||
<TipKeybindLabel actionId="composer.send" text={c.send} />
|
||||
)
|
||||
}
|
||||
>
|
||||
<Button
|
||||
aria-label={busy ? busyLabel : c.send}
|
||||
aria-label={showStop ? c.stop : c.send}
|
||||
className={PRIMARY_ICON_BTN}
|
||||
disabled={disabled || !canSubmit}
|
||||
type="submit"
|
||||
>
|
||||
{busy ? (
|
||||
busyAction === 'queue' ? (
|
||||
<Layers3 className={iconSize.sm} />
|
||||
) : busyAction === 'steer' ? (
|
||||
<SteeringWheel className={iconSize.sm} />
|
||||
) : (
|
||||
<span className="block size-2.5 rounded-[0.1875rem] bg-current" />
|
||||
)
|
||||
{showStop ? (
|
||||
<span className="block size-2.5 rounded-[0.1875rem] bg-current" />
|
||||
) : (
|
||||
<Codicon name="arrow-up" size="0.875rem" />
|
||||
)}
|
||||
|
||||
@@ -79,7 +79,7 @@ describe('useComposerSubmit busy-turn routing', () => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('steers a plain-text follow-up instead of queueing or stopping', async () => {
|
||||
it('treats a payload mid-turn as send (steer), not stop', async () => {
|
||||
const { hook, onCancel, onSteer, onSubmit, queueCurrentDraft } = renderSubmitHook({
|
||||
busy: true,
|
||||
text: 'change course'
|
||||
|
||||
@@ -2,7 +2,7 @@ import { cleanup } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { createClientSessionState } from '@/lib/chat-runtime'
|
||||
import { $activeSessionId, $busy, $messages } from '@/store/session'
|
||||
import { $activeSessionId, $busy, $messages, $selectedStoredSessionId } from '@/store/session'
|
||||
import { $sessionStates, dropSessionState, publishSessionState } from '@/store/session-states'
|
||||
|
||||
import { PRIMARY_SESSION_VIEW } from './session-view'
|
||||
@@ -32,6 +32,7 @@ describe('primary session view reads its own session slice', () => {
|
||||
beforeEach(() => {
|
||||
$sessionStates.set({})
|
||||
$activeSessionId.set(null)
|
||||
$selectedStoredSessionId.set(null)
|
||||
$messages.set([])
|
||||
$busy.set(false)
|
||||
})
|
||||
@@ -74,6 +75,15 @@ describe('primary session view reads its own session slice', () => {
|
||||
expect(PRIMARY_SESSION_VIEW.$messagesEmpty.get()).toBe(false)
|
||||
})
|
||||
|
||||
it('does not mark B busy when A is still running and B has no slice yet', () => {
|
||||
publishSessionState('runtime-a', stateWith('runtime-a', 'session A turn', true))
|
||||
$busy.set(true)
|
||||
$activeSessionId.set(null)
|
||||
$selectedStoredSessionId.set('stored-runtime-b')
|
||||
|
||||
expect(PRIMARY_SESSION_VIEW.$busy.get()).toBe(false)
|
||||
})
|
||||
|
||||
it('returns to the draft atoms when the active session state is dropped', () => {
|
||||
publishSessionState('runtime-a', stateWith('runtime-a', 'session A turn', true))
|
||||
$activeSessionId.set('runtime-a')
|
||||
|
||||
@@ -76,10 +76,21 @@ function primaryField<T>(select: (state: ClientSessionState) => T, $draft: Reada
|
||||
|
||||
const $primaryMessages = primaryField<ChatMessage[]>(state => state.messages, $messages)
|
||||
|
||||
/**
|
||||
* Turn-busy for the workspace pane. A selected stored session that has no
|
||||
* slice yet (cold resume) must stay idle — the global `$busy` atom is a
|
||||
* leftover from whichever session last published, and inheriting it is how
|
||||
* focusing B while A runs marked B busy. The draft atom is only for a true
|
||||
* new chat (no stored id) so the first-send optimistic lock still paints.
|
||||
*/
|
||||
const $primaryBusy = computed([$primaryState, $busy, $selectedStoredSessionId], (state, draftBusy, selected) =>
|
||||
state ? state.busy : Boolean(selected) ? false : draftBusy
|
||||
)
|
||||
|
||||
export const PRIMARY_SESSION_VIEW: SessionView = {
|
||||
kind: 'primary',
|
||||
$awaitingResponse: primaryField<boolean>(state => state.awaitingResponse, $awaitingResponse),
|
||||
$busy: primaryField<boolean>(state => state.busy, $busy),
|
||||
$busy: $primaryBusy,
|
||||
$cwd: primaryField<string>(state => state.cwd, $currentCwd),
|
||||
$fast: primaryField<boolean>(state => state.fast, $currentFastMode),
|
||||
$lastVisibleIsUser: computed($primaryMessages, lastVisibleMessageIsUser),
|
||||
|
||||
@@ -1209,6 +1209,10 @@ describe('usePromptActions slash.exec dispatch payloads', () => {
|
||||
// never heard about. The busy path must park the kickoff on the composer
|
||||
// queue so the settle drain sends it.
|
||||
$queuedPromptsBySession.set({})
|
||||
publishSessionState(RUNTIME_SESSION_ID, {
|
||||
...createClientSessionState(RUNTIME_SESSION_ID),
|
||||
busy: true
|
||||
})
|
||||
|
||||
const calls: { method: string; params?: Record<string, unknown> }[] = []
|
||||
const states: Record<string, unknown>[] = []
|
||||
@@ -1262,6 +1266,7 @@ describe('usePromptActions slash.exec dispatch payloads', () => {
|
||||
expect(renderedText).toContain('⊙ Goal set (20-turn budget): ship the release notes')
|
||||
expect(renderedText).toContain('queued')
|
||||
|
||||
dropSessionState(RUNTIME_SESSION_ID)
|
||||
$queuedPromptsBySession.set({})
|
||||
})
|
||||
|
||||
@@ -2159,8 +2164,12 @@ describe('usePromptActions submit / queue drain semantics', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('a normal (non-queue) submit still respects the busyRef guard', async () => {
|
||||
const busyRef = { current: true }
|
||||
it('a normal (non-queue) submit is blocked when the target session is busy', async () => {
|
||||
publishSessionState(RUNTIME_SESSION_ID, {
|
||||
...createClientSessionState(RUNTIME_SESSION_ID),
|
||||
busy: true
|
||||
})
|
||||
const busyRef = { current: false }
|
||||
const requestGateway = vi.fn(async () => ({}) as never)
|
||||
|
||||
let handle: HarnessHandle | null = null
|
||||
@@ -2177,6 +2186,7 @@ describe('usePromptActions submit / queue drain semantics', () => {
|
||||
|
||||
expect(accepted).toBe(false)
|
||||
expect(requestGateway).not.toHaveBeenCalledWith('prompt.submit', expect.anything())
|
||||
dropSessionState(RUNTIME_SESSION_ID)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
setMessages,
|
||||
setTurnStartedAt
|
||||
} from '@/store/session'
|
||||
import { $sessionStates } from '@/store/session-states'
|
||||
import { clearSessionSubagents } from '@/store/subagents'
|
||||
import { clearSessionTodos } from '@/store/todos'
|
||||
import { setSessionDraftingTool } from '@/store/tool-drafting'
|
||||
@@ -861,7 +862,7 @@ export function usePromptActions({
|
||||
// stale session deletes the wrong transcript.
|
||||
const sessionId = activeSessionIdRef.current
|
||||
|
||||
if (!sessionId || $busy.get()) {
|
||||
if (!sessionId || $sessionStates.get()[sessionId]?.busy) {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
isSessionNotFoundError,
|
||||
isSessionRecentlyInterrupted,
|
||||
isSubmitInFlight,
|
||||
isTargetSessionBusy,
|
||||
markSessionRecentlyInterrupted,
|
||||
readFileDataUrlForAttach,
|
||||
RECENT_INTERRUPT_COOLDOWN_MS,
|
||||
@@ -99,6 +100,19 @@ describe('submit in-flight TTL', () => {
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
describe('isTargetSessionBusy', () => {
|
||||
it('reads the target session slice, not the leftover foreground flag', () => {
|
||||
expect(isTargetSessionBusy({ a: { busy: true }, b: { busy: false } }, 'b', true)).toBe(false)
|
||||
expect(isTargetSessionBusy({ a: { busy: true } }, 'b', true)).toBe(false)
|
||||
})
|
||||
|
||||
it('uses the focused draft flag only when there is no session id', () => {
|
||||
expect(isTargetSessionBusy({}, null, true)).toBe(true)
|
||||
expect(isTargetSessionBusy({}, null, false)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isSessionIdCandidate', () => {
|
||||
it('accepts the timestamped and hex id forms', () => {
|
||||
expect(isSessionIdCandidate('20260101_120000_abc123')).toBe(true)
|
||||
|
||||
@@ -199,18 +199,22 @@ export async function withSessionNotFoundResume<T>(
|
||||
* blocks an IDLE target and reports "session busy" about a session doing
|
||||
* nothing, and the converse lets a background send fire mid-turn.
|
||||
*
|
||||
* The published per-session state is authoritative. Fall back to the
|
||||
* foreground flag only when the target has no state yet — a just-minted
|
||||
* session whose first publish hasn't landed.
|
||||
* The published per-session state is authoritative. A known target with no
|
||||
* slice yet is idle — never inherit another session's leftover foreground
|
||||
* flag (focusing B while A runs). Fall back to the foreground flag only for
|
||||
* a true draft (no session id), where that flag must be the focused view's
|
||||
* busy, not a process-global lock.
|
||||
*/
|
||||
export function isTargetSessionBusy(
|
||||
sessionStates: Record<string, { busy: boolean }>,
|
||||
sessionId: null | string,
|
||||
foregroundBusy: boolean
|
||||
): boolean {
|
||||
const state = sessionId ? sessionStates[sessionId] : undefined
|
||||
if (!sessionId) {
|
||||
return foregroundBusy
|
||||
}
|
||||
|
||||
return state ? state.busy : foregroundBusy
|
||||
return Boolean(sessionStates[sessionId]?.busy)
|
||||
}
|
||||
|
||||
// Gateway JSON-RPC calls reject with "request timed out: <method>" when the
|
||||
|
||||
@@ -705,6 +705,10 @@ export function useSessionActions({
|
||||
if (!takeWarmCache()) {
|
||||
setActiveSessionId(null)
|
||||
activeSessionIdRef.current = null
|
||||
// History load is not turn-busy. Drop the previous session's leftover
|
||||
// lock so focusing this session cannot inherit another chat's run.
|
||||
busyRef.current = false
|
||||
setBusy(false)
|
||||
|
||||
if (!resumedSameSelectedSession) {
|
||||
setMessages([])
|
||||
@@ -990,9 +994,10 @@ export function useSessionActions({
|
||||
setMessages([])
|
||||
}
|
||||
|
||||
// A history load is not a live turn. Toggling busy here and again in the
|
||||
// finally block re-renders the thread viewport after it has loaded.
|
||||
busyRef.current = true
|
||||
// A history load is not a live turn. Do not mark the incoming session
|
||||
// busy — running ≠ loading, and a leftover true locked the composer.
|
||||
busyRef.current = false
|
||||
setBusy(false)
|
||||
setAwaitingResponse(false)
|
||||
clearNotifications()
|
||||
setSelectedStoredSessionId(storedSessionId)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { type MutableRefObject, useCallback, useEffect, useRef } from 'react'
|
||||
|
||||
import { PRIMARY_SESSION_VIEW } from '@/app/chat/session-view'
|
||||
import type { ChatMessage } from '@/lib/chat-messages'
|
||||
import { preserveLocalAssistantErrors } from '@/lib/chat-messages'
|
||||
import { createClientSessionState } from '@/lib/chat-runtime'
|
||||
@@ -8,7 +9,6 @@ import { persistInFlightTurnState } from '@/lib/inflight-turn-journal'
|
||||
import { setMutableRef } from '@/lib/mutable-ref'
|
||||
import {
|
||||
$activeSessionId,
|
||||
$busy,
|
||||
$messages,
|
||||
setActiveSessionStoredIdRotation,
|
||||
setCurrentFastMode,
|
||||
@@ -54,7 +54,7 @@ export function useSessionStateCache({
|
||||
setBusy,
|
||||
setMessages
|
||||
}: SessionStateCacheOptions) {
|
||||
const busy = useStore($busy)
|
||||
const busy = useStore(PRIMARY_SESSION_VIEW.$busy)
|
||||
const sessionTiles = useStore($sessionTiles)
|
||||
const activeSessionIdRef = useRef<string | null>(activeSessionId)
|
||||
const selectedStoredSessionIdRef = useRef<string | null>(selectedStoredSessionId)
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { createClientSessionState } from '@/lib/chat-runtime'
|
||||
import { $gatewayState } from '@/store/session'
|
||||
import { $sessionStates, dropSessionState, publishSessionState } from '@/store/session-states'
|
||||
|
||||
import { host } from './index'
|
||||
|
||||
describe('host.state busy vs gateway', () => {
|
||||
afterEach(() => {
|
||||
$sessionStates.set({})
|
||||
$gatewayState.set('idle')
|
||||
})
|
||||
|
||||
it('exposes per-session turn-busy and does not treat gateway as busy', () => {
|
||||
const running = { ...createClientSessionState('stored-a'), busy: true }
|
||||
const idle = { ...createClientSessionState('stored-b'), busy: false }
|
||||
|
||||
publishSessionState('runtime-a', running)
|
||||
publishSessionState('runtime-b', idle)
|
||||
$gatewayState.set('open')
|
||||
|
||||
expect(host.state.busyBySession.get()).toEqual({ 'runtime-a': true, 'runtime-b': false })
|
||||
expect(host.state.gateway.get()).toBe('open')
|
||||
|
||||
dropSessionState('runtime-a')
|
||||
expect(host.state.busyBySession.get()['runtime-a']).toBeUndefined()
|
||||
expect(host.state.gateway.get()).toBe('open')
|
||||
})
|
||||
})
|
||||
@@ -45,7 +45,7 @@ import {
|
||||
$gatewayState,
|
||||
$selectedStoredSessionId
|
||||
} from '@/store/session'
|
||||
import { $focusedSessionState, $focusedStoredSessionId } from '@/store/session-states'
|
||||
import { $focusedSessionState, $focusedStoredSessionId, $sessionStates } from '@/store/session-states'
|
||||
import { runGatewayRestart } from '@/store/system-actions'
|
||||
|
||||
// -- state: readonly views over the app's live atoms -------------------------
|
||||
@@ -90,6 +90,17 @@ const readViewport = (): ViewportRect => ({
|
||||
narrow: $narrowViewport.get()
|
||||
})
|
||||
|
||||
/** Runtime session id → mid-turn. Not gateway socket state. */
|
||||
const $busyBySession = computed($sessionStates, states => {
|
||||
const map: Record<string, boolean> = {}
|
||||
|
||||
for (const [id, state] of Object.entries(states)) {
|
||||
map[id] = Boolean(state.busy)
|
||||
}
|
||||
|
||||
return map
|
||||
})
|
||||
|
||||
const $viewport = atom<ViewportRect>(readViewport())
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
@@ -111,9 +122,11 @@ export const host = {
|
||||
* id uses the global flag.
|
||||
*/
|
||||
busy: readonlyAtom<boolean>($focusedBusy),
|
||||
/** Runtime session id → mid-turn. Not socket state; see `gateway`. */
|
||||
busyBySession: readonlyAtom<Record<string, boolean>>($busyBySession),
|
||||
/** Active workspace cwd ('' when detached). */
|
||||
cwd: readonlyAtom<string>($currentCwd),
|
||||
/** Gateway socket state: 'idle' | 'connecting' | 'open' | …. */
|
||||
/** Gateway socket state: 'idle' | 'connecting' | 'open' | …. Not turn-busy. */
|
||||
gateway: readonlyAtom<string>($gatewayState),
|
||||
/** Current main model slug. */
|
||||
model: readonlyAtom<string>($currentModel),
|
||||
|
||||
@@ -55,10 +55,13 @@ The ONLY import surface is `@hermes/plugin-sdk` (plus `react` /
|
||||
`jsx()` calls, not JSX syntax; the file is not compiled).
|
||||
|
||||
- `host.state.*` — readonly reactive atoms: `activeSessionId`, `busy`,
|
||||
`awaitingResponse`, `cwd`, `gateway`, `model`, `profile`, `viewport`.
|
||||
`awaitingResponse`, `busyBySession`, `cwd`, `gateway` (socket state, not
|
||||
turn-busy), `model`, `profile`, `viewport`.
|
||||
`busy` is true while the focused chat is working after a send (thinking
|
||||
and streaming). `awaitingResponse` is true until the first assistant
|
||||
payload. Read with `.get()` in handlers, `useValue(atom)` in components.
|
||||
payload. `busyBySession` maps runtime session id → mid-turn, for rosters
|
||||
that watch every session. Read with `.get()` in handlers,
|
||||
`useValue(atom)` in components.
|
||||
- `host.request(method, params)` — gateway JSON-RPC (sessions, config,
|
||||
skills, cron — everything the app uses).
|
||||
- `host.onEvent(type, fn)` — live gateway events (`'*'` for all). Returns a
|
||||
|
||||
@@ -39,7 +39,8 @@ module and never touches app internals (they are lint-fenced out of a bundled
|
||||
plugin, and fail to resolve in a disk plugin). Capability comes in tiers:
|
||||
|
||||
- **`host.state.*`** — readonly views over the app's live state (nanostore
|
||||
atoms): active session, cwd, gateway status, model, profile, viewport.
|
||||
atoms): active session, per-session turn-busy, cwd, gateway socket status,
|
||||
model, profile, viewport. `gateway` is the WebSocket, not turn-busy.
|
||||
- **`host.*` actions** — curated safe verbs: toast, navigate, tail logs,
|
||||
restart the gateway, subscribe to the gateway event stream.
|
||||
- **`host.request`** — the gateway JSON-RPC door: sessions, config, skills,
|
||||
@@ -376,12 +377,22 @@ components.
|
||||
host.state.activeSessionId // ReadableAtom<string | null>
|
||||
host.state.awaitingResponse // ReadableAtom<boolean> true until the first assistant payload
|
||||
host.state.busy // ReadableAtom<boolean> focused chat is working after a send
|
||||
host.state.busyBySession // ReadableAtom<Record<string, boolean>> runtime id → mid-turn
|
||||
host.state.cwd // ReadableAtom<string>
|
||||
host.state.gateway // ReadableAtom<string> ('idle' | 'connecting' | 'open' | …)
|
||||
host.state.gateway // ReadableAtom<string> socket state ('idle' | 'connecting' | 'open' | …)
|
||||
host.state.model // ReadableAtom<string>
|
||||
host.state.profile // ReadableAtom<string>
|
||||
host.state.viewport // ReadableAtom<{ width, height, narrow }>
|
||||
```
|
||||
|
||||
`host.state.gateway` is the WebSocket connection, not whether a chat turn is
|
||||
running. A session can be mid-turn while the socket is `open`; another session
|
||||
can be idle at the same time. Disable composer or plugin actions from the
|
||||
**focused session's** turn-busy (`host.state.busyBySession[sessionId]`, or that
|
||||
session's `view.$busy`) — never from `gateway`, and never from a process-global
|
||||
busy flag.
|
||||
|
||||
```ts
|
||||
host.notify({ kind, message, title?, detail?, action? }) // toast; returns id
|
||||
host.notifyError(error, fallbackMessage) // toast an error
|
||||
ctx.os.notify({ title, body?, silent? }) // native OS notification (attributed to your plugin)
|
||||
|
||||
Reference in New Issue
Block a user