fix(client): command lifecycle rows keep the composer blank

Selecting a preset from the hero pushed the session into the conversation
view: the /permission switch logs its command/run + command/done pair, the
pair folds into flow nodes, and the composerPhase predicate counted ANY
node as conversation — so the hero (composerPhase === 'blank') collapsed.
The host-side blank bit was already correct (sessionBlank = no turn/start;
knob events open no turn), but the client derives its phase from window
content, and command rows are log-only records, not conversation.

derivePhase's hasContent now excludes command nodes — the client mirror of
the host predicate. The knob events themselves never fold (not
surface-eligible), so the pair was the only leak. Covers /plan on the hero
identically (same lifecycle pair, same predicate).

Specs: the host blank spec pins the three knob events as standalone
events; a session spec drives the /permission pair through the live path
and asserts phase stays 'blank' while the command node renders.
This commit is contained in:
imccyu
2026-07-29 11:33:44 +08:00
parent 5c432955ed
commit 62c1f15576
3 changed files with 29 additions and 3 deletions

View File

@@ -827,7 +827,10 @@ export class Session implements SessionFace {
queue: this.queueCache.value,
running: this.running,
composerPhase: derivePhase(
nodes.length > 0 || partial !== null || this.running || this.pendingCache.value.length > 0,
// Command lifecycle nodes are not conversation: running /permission
// or /plan on a fresh session keeps the hero (the client mirror of
// the host's no-turn sessionBlank predicate).
nodes.some(node => node.kind !== 'command') || partial !== null || this.running || this.pendingCache.value.length > 0,
this.promptAttempted,
),
removed: this.removed,
@@ -848,7 +851,7 @@ export class Session implements SessionFace {
* object: `hasContent` only grows within a window and `promptAttempted` is
* sticky, so blank → engaging → active never steps back; a failed first
* prompt stays engaging (retry semantics — see ComposerPhase).
* @param hasContent - any conversation material exists (nodes, partial, running turn, pending waits).
* @param hasContent - any conversation material exists (non-command nodes, partial, running turn, pending waits; command lifecycle rows alone keep the session blank).
* @param promptAttempted - a prompt was initiated on this session object.
* @returns the derived phase.
*/

View File

@@ -126,6 +126,21 @@ describe('live event path', () => {
})
})
it('command lifecycle rows alone keep the composer blank (hero survives a /permission or /plan switch)', async () => {
// A fresh session whose only window content is a command pair (plus the
// knob events a /permission switch appends — not surface-eligible, so
// they never become nodes) stays phase 'blank': selecting a preset from
// the hero must not enter the conversation view.
const { session } = await opened([])
expect(session.getSnapshot().composerPhase).toBe('blank')
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
feed(ev.commandRun(0, 'cmd-perm', 'permission', ' danger-full-access'))
feed(ev.commandDone(1, 'cmd-perm', 'success', 'Permission preset: danger-full-access.'))
const snapshot = session.getSnapshot()
expect(snapshot.nodes.at(-1)).toMatchObject({ kind: 'command', name: 'permission' })
expect(snapshot.composerPhase).toBe('blank')
})
it('accumulates chunks into partial, then finalize swaps partial out as the node lands', async () => {
const { session } = await opened()
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }