60cc51fe21
* feat(assistant): dock the panel into the frame and give the trigger a status channel Two things the panel could not do. It covered the page it was talking about. Opening it on /invoices laid a 480px curtain over the invoice, so verifying an answer meant closing the thing that gave it. The page panel now gives up that width plus the frame's own gutter, and the two float side by side. Docking applies at the compact width only: expanded is a deliberate focus mode, where there is no page left to read anyway, so it goes back to overlaying. Driven by a --agent-dock-w custom property because the frame layout is a server component; globals.css seeds the default so the first paint is not a jump, and below md nothing changes. And a minimized session was silent. The agent could be three tool calls into a booking, or finished ten minutes ago, and the pill said "Fortsätt med Anna" either way, so the only way to find out was to reopen it. There is now one status channel: the trigger spins with the current step while work runs and shows an unread dot when a turn landed behind a hidden panel. The channel is a reducer in a React-free module rather than a pair of booleans, because a durable background run has to publish to the same one later. Its 'detached' state (working somewhere the user cannot see) is built and rendered now even though nothing dispatches it in v1, so adding runs is a publisher and not a redesign. Turn boundaries derive from the streaming flag rather than being published per call site: a turn can end by completing, erroring, aborting or being stopped, and missing one would leave the trigger claiming the agent is still working forever. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs: record the Sonnet 5 ceiling, dock and status-channel decisions Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
121 lines
4.2 KiB
TypeScript
121 lines
4.2 KiB
TypeScript
import { describe, it, expect } from 'vitest'
|
|
import {
|
|
INITIAL_AGENT_STATUS,
|
|
collapsedStatusLabel,
|
|
reduceAgentStatus,
|
|
type AgentStatus,
|
|
type AgentStatusEvent,
|
|
} from '../agent-status'
|
|
|
|
/**
|
|
* What this protects: a user who minimizes the panel mid-answer used to lose
|
|
* every signal about it. The reducer is what decides whether the trigger says
|
|
* "arbetar", "är klar", or nothing at all, so these are the real transitions,
|
|
* not a description of them.
|
|
*/
|
|
function run(events: AgentStatusEvent[], from: AgentStatus = INITIAL_AGENT_STATUS): AgentStatus {
|
|
return events.reduce(reduceAgentStatus, from)
|
|
}
|
|
|
|
describe('reduceAgentStatus', () => {
|
|
it('reports a finished turn only when it finished out of sight', () => {
|
|
const onScreen = run([{ type: 'turn_start' }, { type: 'turn_end' }])
|
|
expect(onScreen.activity).toBe('idle')
|
|
|
|
const hidden = run([
|
|
{ type: 'turn_start' },
|
|
{ type: 'visibility', visible: false },
|
|
{ type: 'turn_end' },
|
|
])
|
|
expect(hidden.activity).toBe('done')
|
|
})
|
|
|
|
it('clears the finished-turn signal once the panel is back on screen', () => {
|
|
const seen = run([
|
|
{ type: 'turn_start' },
|
|
{ type: 'visibility', visible: false },
|
|
{ type: 'turn_end' },
|
|
{ type: 'visibility', visible: true },
|
|
])
|
|
expect(seen.activity).toBe('idle')
|
|
expect(seen.step).toBeNull()
|
|
})
|
|
|
|
it('does not cancel work that is still running when the panel reopens', () => {
|
|
// Reopening mid-answer must not read as "nothing is happening": the pill
|
|
// would go quiet while the turn was still streaming.
|
|
const stillWorking = run([
|
|
{ type: 'turn_start' },
|
|
{ type: 'step', label: 'Bokar avskrivningar…' },
|
|
{ type: 'visibility', visible: false },
|
|
{ type: 'visibility', visible: true },
|
|
])
|
|
expect(stillWorking.activity).toBe('working')
|
|
expect(stillWorking.step).toBe('Bokar avskrivningar…')
|
|
})
|
|
|
|
it('keeps the latest step and drops it when the turn ends', () => {
|
|
const mid = run([
|
|
{ type: 'turn_start' },
|
|
{ type: 'step', label: 'Hämtar underlag…' },
|
|
{ type: 'step', label: 'Bokar avskrivningar…' },
|
|
])
|
|
expect(mid.step).toBe('Bokar avskrivningar…')
|
|
expect(reduceAgentStatus(mid, { type: 'turn_end' }).step).toBeNull()
|
|
})
|
|
|
|
it('treats a step for work it never saw start as working', () => {
|
|
// A stream resumed after a remount can deliver a tool event with no
|
|
// turn_start behind it; dropping it would leave the pill idle mid-turn.
|
|
expect(reduceAgentStatus(INITIAL_AGENT_STATUS, { type: 'step', label: 'Bokar…' }).activity).toBe(
|
|
'working',
|
|
)
|
|
})
|
|
|
|
it('remembers visibility across a reset so the next turn reports correctly', () => {
|
|
const afterReset = run([
|
|
{ type: 'visibility', visible: false },
|
|
{ type: 'turn_start' },
|
|
{ type: 'reset' },
|
|
{ type: 'turn_start' },
|
|
{ type: 'turn_end' },
|
|
])
|
|
expect(afterReset.activity).toBe('done')
|
|
})
|
|
|
|
it('carries the detached state built for durable runs', () => {
|
|
// Nothing dispatches this in v1. It exists so a background run publishes to
|
|
// this channel instead of the pill needing a second one.
|
|
const detached = reduceAgentStatus(INITIAL_AGENT_STATUS, {
|
|
type: 'detached',
|
|
label: 'Kör momsavstämning',
|
|
})
|
|
expect(detached.activity).toBe('detached')
|
|
expect(collapsedStatusLabel(detached, 'Anna')).toBe('Kör momsavstämning')
|
|
})
|
|
})
|
|
|
|
describe('collapsedStatusLabel', () => {
|
|
it('says nothing when there is nothing to say', () => {
|
|
expect(collapsedStatusLabel(INITIAL_AGENT_STATUS, 'Anna')).toBeNull()
|
|
})
|
|
|
|
it('drops the narration ellipsis so the pill does not read as two spinners', () => {
|
|
const working = run([{ type: 'turn_start' }, { type: 'step', label: 'Bokar avskrivningar…' }])
|
|
expect(collapsedStatusLabel(working, 'Anna')).toBe('Bokar avskrivningar')
|
|
})
|
|
|
|
it('falls back to the agent name while working without a named step', () => {
|
|
expect(collapsedStatusLabel(run([{ type: 'turn_start' }]), 'Anna')).toBe('Anna arbetar')
|
|
})
|
|
|
|
it('announces a finished turn by name', () => {
|
|
const done = run([
|
|
{ type: 'visibility', visible: false },
|
|
{ type: 'turn_start' },
|
|
{ type: 'turn_end' },
|
|
])
|
|
expect(collapsedStatusLabel(done, 'Anna')).toBe('Anna är klar')
|
|
})
|
|
})
|