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:
@@ -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([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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' | …. */
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user