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:
Cary Palmer
2026-08-15 19:13:12 -05:00
committed by Teknium
parent 1e54e05227
commit 3bc52fb9df
15 changed files with 153 additions and 48 deletions
@@ -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+↵')
+10 -23
View File
@@ -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'
+11 -1
View File
@@ -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')
+12 -1
View File
@@ -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)
+30
View File
@@ -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')
})
})
+15 -2
View File
@@ -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)