feat(desktop): expose busy turn flags on plugin SDK

Plugins can now read host.state.busy and host.state.awaitingResponse
for the focused chat. These follow the same session slice the chat pane
uses, so a draft falls back to the global flags and a background turn
does not leak.
This commit is contained in:
Adolanium
2026-08-16 11:30:27 +03:00
committed by Teknium
parent 39bf984765
commit 0a9337d2bb
5 changed files with 95 additions and 4 deletions
+2 -1
View File
@@ -39,10 +39,11 @@ const $events = atom(0)
function ClickCounter() {
const count = useValue($clicks)
const events = useValue($events)
const busy = useValue(host.state.busy)
const gateway = useValue(host.state.gateway)
return (
<Tip label={`Example plugin — gateway ${gateway}, ${events} events heard`}>
<Tip label={`Example plugin: gateway ${gateway}, ${busy ? 'working' : 'idle'}, ${events} events heard`}>
<button
className={cn(
'inline-flex h-full items-center gap-1 rounded-none px-1.5 text-[0.6875rem] tabular-nums transition-colors',
+66
View File
@@ -0,0 +1,66 @@
import { afterEach, describe, expect, it } from 'vitest'
import { createClientSessionState } from '@/lib/chat-runtime'
import { host } from '@/sdk'
import {
setActiveSessionId,
setAwaitingResponse,
setBusy
} from '@/store/session'
import { clearAllSessionStates, publishSessionState } from '@/store/session-states'
describe('host.state turn flags', () => {
afterEach(() => {
setActiveSessionId(null)
setBusy(false)
setAwaitingResponse(false)
clearAllSessionStates()
})
it('uses the draft atoms when there is no runtime session', () => {
expect(host.state.busy.get()).toBe(false)
expect(host.state.awaitingResponse.get()).toBe(false)
setBusy(true)
setAwaitingResponse(true)
expect(host.state.busy.get()).toBe(true)
expect(host.state.awaitingResponse.get()).toBe(true)
})
it('reads the focused session slice once a runtime exists', () => {
setBusy(false)
setAwaitingResponse(false)
setActiveSessionId('rt-focus')
publishSessionState('rt-focus', {
...createClientSessionState('stored-focus'),
awaitingResponse: true,
busy: true
})
expect(host.state.busy.get()).toBe(true)
expect(host.state.awaitingResponse.get()).toBe(true)
publishSessionState('rt-focus', {
...createClientSessionState('stored-focus'),
awaitingResponse: false,
busy: true
})
expect(host.state.busy.get()).toBe(true)
expect(host.state.awaitingResponse.get()).toBe(false)
})
it('does not pick up a background session', () => {
setActiveSessionId('rt-focus')
publishSessionState('rt-focus', createClientSessionState('stored-focus'))
publishSessionState('rt-bg', {
...createClientSessionState('stored-bg'),
awaitingResponse: true,
busy: true
})
expect(host.state.busy.get()).toBe(false)
expect(host.state.awaitingResponse.get()).toBe(false)
})
})
+9
View File
@@ -20,6 +20,7 @@
import { atom, type ReadableAtom } from 'nanostores'
import { PRIMARY_SESSION_VIEW } from '@/app/chat/session-view'
import { openSession, type OpenSessionIntent } from '@/app/open-session'
import { $narrowViewport } from '@/components/pane-shell/tree/store'
import { onGatewayEvent } from '@/contrib/events'
@@ -69,6 +70,14 @@ export const host = {
state: {
/** Runtime id of the active chat session (null on a fresh draft). */
activeSessionId: readonlyAtom<null | string>($activeSessionId),
/** True from send until the first assistant payload on the focused chat. */
awaitingResponse: readonlyAtom<boolean>(PRIMARY_SESSION_VIEW.$awaitingResponse),
/**
* True while the focused chat is working after a send. Covers the wait
* for the first token and the stream that follows. Same session as
* `activeSessionId`. A draft with no runtime id uses the global flag.
*/
busy: readonlyAtom<boolean>(PRIMARY_SESSION_VIEW.$busy),
/** Active workspace cwd ('' when detached). */
cwd: readonlyAtom<string>($currentCwd),
/** Gateway socket state: 'idle' | 'connecting' | 'open' | …. */
@@ -54,9 +54,11 @@ The ONLY import surface is `@hermes/plugin-sdk` (plus `react` /
`react/jsx-runtime`, which resolve to the app's own React — write UI with
`jsx()` calls, not JSX syntax; the file is not compiled).
- `host.state.*` — readonly reactive atoms: `activeSessionId`, `cwd`,
`gateway`, `model`, `profile`, `viewport`. Read with `.get()` in handlers,
`useValue(atom)` in components.
- `host.state.*` — readonly reactive atoms: `activeSessionId`, `busy`,
`awaitingResponse`, `cwd`, `gateway`, `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.
- `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
@@ -374,6 +374,8 @@ components.
```ts
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.cwd // ReadableAtom<string>
host.state.gateway // ReadableAtom<string> ('idle' | 'connecting' | 'open' | …)
host.state.model // ReadableAtom<string>
@@ -405,6 +407,17 @@ cron, kanban, …). Profile-shaped plugins get first-class methods too:
probe) and `profiles.create` (`name`, `description`, `clone_from`,
`clone_all`, `no_skills`, `soul`, optional `model` + `provider` pin) — the
ws twins of the dashboard's `/api/profiles` REST routes.
`host.state.busy` is the focused chat's live turn (thinking and streaming).
`host.state.awaitingResponse` stays true from send until the first assistant
payload. Both follow `activeSessionId`. Subscribe in a component:
```javascript
const busy = useValue(host.state.busy)
```
For token-level detail, listen with `host.onEvent` (`message.start`,
`message.delta`, `message.complete`).
`host.onEvent` streams live gateway events (message deltas,
session lifecycle, tool activity). Listeners are isolated — a throw in your
listener can't affect app dispatch. Every `host` door is async-safe: a sync throw