feat(gui): generic command flow node and the conversation.chat.commandview keyed slot

The FoldAdapter folds the log-only command/run + command/done pair (paired
by commandId) into a CommandNode outside the surface fold and merges the
nodes into the flow by seq; cross-window cuts soft-fall like tool pairs (a
done-only window builds the node from the done, a run with no done renders
as still executing). ChatView renders command nodes through the new keyed
'conversation.chat.commandview' hole (key = command name) with
GenericCommandCard — a stripped-down GenericToolCard showing the command
line and outcome text — as the render-site fallback, so any slash command
renders durably with zero registration and survives refresh, other tabs,
and resume via the mux-broadcast events.
This commit is contained in:
imccyu
2026-07-27 17:38:03 +08:00
parent 4e50369eb6
commit ba928c5517
13 changed files with 316 additions and 13 deletions

View File

@@ -99,6 +99,28 @@ describe('live event path', () => {
expect(session.getSnapshot().nodes).toEqual(before.nodes)
})
it('materializes a command node from live lifecycle frames and reproduces it from a history window', async () => {
// Live path: run mints an executing node, done settles it in the flow.
const { session } = await opened()
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
feed(ev.commandRun(6, 'cmd-live', 'plan', '/plan'))
let command = session.getSnapshot().nodes.at(-1)
expect(command).toMatchObject({ kind: 'command', name: 'plan', line: '/plan', outcome: null })
feed(ev.commandDone(7, 'cmd-live', 'success', '已进入 plan mode'))
command = session.getSnapshot().nodes.at(-1)
expect(command).toMatchObject({ kind: 'command', seq: 6, outcome: { kind: 'success', text: '已进入 plan mode' } })
// Replay path (refresh): the same pair inside the history window folds identically.
const replayed = await opened([
...plainTurn(0, 0, 'a', 'b'),
ev.commandRun(6, 'cmd-live', 'plan', '/plan'),
ev.commandDone(7, 'cmd-live', 'success', '已进入 plan mode'),
])
expect(replayed.session.getSnapshot().nodes.at(-1)).toMatchObject({
kind: 'command', seq: 6, name: 'plan', outcome: { kind: 'success', text: '已进入 plan mode' },
})
})
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 }) }