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:
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user