fix(desktop): make plugin SDK turn flags follow the focused chat

Follow-up to the salvaged #87558 commit: the PR's docs promised the flags
follow "the focused chat", but PRIMARY_SESSION_VIEW is the primary
workspace tab only — a focused session TILE would read the wrong chat.
Wire host.state.busy / host.state.awaitingResponse through the focused
slice ($focusedStoredSessionId / $focusedSessionState), same semantics
as the statusbar busy pulse, with the primary view (and its draft
fallback) while the workspace holds focus.

Adds a tile-focus vitest case and corrects the docs wording.
This commit is contained in:
Teknium
2026-08-16 02:06:14 -07:00
parent 0a9337d2bb
commit 28fc9d9c0d
3 changed files with 89 additions and 7 deletions

View File

@@ -63,4 +63,51 @@ describe('host.state turn flags', () => {
expect(host.state.busy.get()).toBe(false)
expect(host.state.awaitingResponse.get()).toBe(false)
})
it('follows a focused session tile, not the primary', async () => {
const tree = await import('@/components/pane-shell/tree/store')
const model = await import('@/components/pane-shell/tree/model')
const { registry } = await import('@/contrib/registry')
const { $sessionTiles } = await import('@/store/session-states')
// A second chat zone holding a session tile, next to the main workspace.
for (const id of ['workspace', 'session-tile:tile-a']) {
registry.register({
area: 'panes',
data: id === 'workspace' ? { placement: 'main', uncloseable: true } : { placement: 'main' },
id,
render: () => null,
title: id
})
}
tree.declareDefaultTree(
model.split('row', [
model.group(['workspace'], { active: 'workspace', id: 'grp-main' }),
model.group(['session-tile:tile-a'], { active: 'session-tile:tile-a', id: 'grp-side' })
])
)
// Primary chat is idle; the tile's session is mid-turn.
setActiveSessionId('rt-primary')
publishSessionState('rt-primary', createClientSessionState('stored-primary'))
$sessionTiles.set([{ runtimeId: 'rt-tile-a', storedSessionId: 'tile-a' }])
publishSessionState('rt-tile-a', {
...createClientSessionState('tile-a'),
awaitingResponse: true,
busy: true
})
// Focusing the tile zone moves the flags onto the tile's session…
tree.noteActiveTreeGroup('grp-side')
expect(host.state.busy.get()).toBe(true)
expect(host.state.awaitingResponse.get()).toBe(true)
// …and homing back to the workspace returns to the (idle) primary.
tree.noteActiveTreeGroup('grp-main')
expect(host.state.busy.get()).toBe(false)
expect(host.state.awaitingResponse.get()).toBe(false)
$sessionTiles.set([])
})
})

View File

@@ -18,10 +18,11 @@
* - `ui.*` — the design language, so plugin UI looks native by default.
*/
import { atom, type ReadableAtom } from 'nanostores'
import { atom, computed, type ReadableAtom } from 'nanostores'
import { PRIMARY_SESSION_VIEW } from '@/app/chat/session-view'
import { openSession, type OpenSessionIntent } from '@/app/open-session'
import type { ClientSessionState } from '@/app/types'
import { $narrowViewport } from '@/components/pane-shell/tree/store'
import { onGatewayEvent } from '@/contrib/events'
import { deleteProfile, getLogs, getStatus, type HermesGateway } from '@/hermes'
@@ -37,13 +38,44 @@ import {
setActiveProfile,
setShowAllProfiles
} from '@/store/profile'
import { $activeSessionId, $currentCwd, $currentModel, $gatewayState } from '@/store/session'
import {
$activeSessionId,
$currentCwd,
$currentModel,
$gatewayState,
$selectedStoredSessionId
} from '@/store/session'
import { $focusedSessionState, $focusedStoredSessionId } from '@/store/session-states'
import { runGatewayRestart } from '@/store/system-actions'
// -- state: readonly views over the app's live atoms -------------------------
const readonlyAtom = <T>(atomLike: ReadableAtom<T>): ReadableAtom<T> => atomLike
/**
* Turn flag for the FOCUSED chat — same semantics as the statusbar's busy
* pulse. While the focused surface is the primary workspace (or a draft with
* no runtime slice yet) this reads the primary view, which itself falls back
* to the global draft atoms. Once a session TILE holds focus, the tile's own
* state slice is authoritative — a background session can never leak in.
*/
const focusedTurnFlag = (
select: (state: ClientSessionState) => boolean,
$primary: ReadableAtom<boolean>
): ReadableAtom<boolean> =>
computed(
[$focusedStoredSessionId, $selectedStoredSessionId, $focusedSessionState, $primary],
(focused, selected, state, primary) =>
!focused || focused === selected ? primary : Boolean(state && select(state))
)
const $focusedBusy = focusedTurnFlag(state => state.busy, PRIMARY_SESSION_VIEW.$busy)
const $focusedAwaitingResponse = focusedTurnFlag(
state => state.awaitingResponse,
PRIMARY_SESSION_VIEW.$awaitingResponse
)
/** Window geometry + the app's responsive posture, one readonly rect. */
export interface ViewportRect {
width: number
@@ -71,13 +103,14 @@ export const host = {
/** 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),
awaitingResponse: readonlyAtom<boolean>($focusedAwaitingResponse),
/**
* 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.
* for the first token and the stream that follows. Follows tile focus —
* same signal the statusbar's busy pulse reads. A draft with no runtime
* id uses the global flag.
*/
busy: readonlyAtom<boolean>(PRIMARY_SESSION_VIEW.$busy),
busy: readonlyAtom<boolean>($focusedBusy),
/** Active workspace cwd ('' when detached). */
cwd: readonlyAtom<string>($currentCwd),
/** Gateway socket state: 'idle' | 'connecting' | 'open' | …. */

View File

@@ -409,7 +409,9 @@ probe) and `profiles.create` (`name`, `description`, `clone_from`,
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:
payload. Both follow the chat the user is actually looking at — the focused
session tile when one holds focus, else the primary workspace chat (the same
signal the statusbar's busy pulse reads). Subscribe in a component:
```javascript
const busy = useValue(host.state.busy)