diff --git a/apps/desktop/src/sdk/index.test.ts b/apps/desktop/src/sdk/index.test.ts index 21b93df60e..a7a87b3ed3 100644 --- a/apps/desktop/src/sdk/index.test.ts +++ b/apps/desktop/src/sdk/index.test.ts @@ -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([]) + }) }) diff --git a/apps/desktop/src/sdk/index.ts b/apps/desktop/src/sdk/index.ts index 330c53d1b7..8367f763e9 100644 --- a/apps/desktop/src/sdk/index.ts +++ b/apps/desktop/src/sdk/index.ts @@ -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 = (atomLike: ReadableAtom): ReadableAtom => 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 +): ReadableAtom => + 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($activeSessionId), /** True from send until the first assistant payload on the focused chat. */ - awaitingResponse: readonlyAtom(PRIMARY_SESSION_VIEW.$awaitingResponse), + awaitingResponse: readonlyAtom($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(PRIMARY_SESSION_VIEW.$busy), + busy: readonlyAtom($focusedBusy), /** Active workspace cwd ('' when detached). */ cwd: readonlyAtom($currentCwd), /** Gateway socket state: 'idle' | 'connecting' | 'open' | …. */ diff --git a/website/docs/developer-guide/desktop-plugin-sdk.md b/website/docs/developer-guide/desktop-plugin-sdk.md index c120f2d138..a83ca60bfc 100644 --- a/website/docs/developer-guide/desktop-plugin-sdk.md +++ b/website/docs/developer-guide/desktop-plugin-sdk.md @@ -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)