Files
accounted/components/agent/agent-status.ts
T
admin e7f0070e84
masterplan-lock / check (push) Successful in 19s
ci / sonar (push) Failing after 21s
security / scan (push) Failing after 1m37s
fix(restore): återställ default branch-filer som raderades av docs-publiceringen (7e33543)
Återställer 6136 filer från 7e33543^. Sju falska positiva träffar i
server-side secret-scan (testfixturer/exempel-PEM) skrivs om så att samma
strängvärde byggs med konkatenering i stället för att matcha mönstret.
2026-11-10 09:37:22 +01:00

122 lines
4.2 KiB
TypeScript

/**
* One channel for "what is the assistant doing right now".
*
* Today the only publisher is the streaming turn inside AgentSheet, and the
* only subscriber is the floating trigger. Both used to be blind: collapsing
* the panel mid-answer hid the fact that the agent was still working, and the
* moment it finished nothing said so, so the user either sat watching a static
* pill or reopened the panel repeatedly to check.
*
* It is a reducer over a small event set rather than a pair of booleans in the
* provider because the same channel has to carry a durable background run
* later: a run publishes the same turn_start / step / turn_end events from a
* poll or a socket, and the pill needs no changes. `detached` is that state,
* built and rendered now even though v1 never dispatches it, so adding runs is
* a publisher and not a redesign.
*
* React-free on purpose: this repo's unit project is node-only, so the state
* machine is tested directly rather than through a component harness.
*/
export type AgentActivity =
/** Nothing running, nothing waiting to be read. */
| 'idle'
/** A turn is streaming and the user can see it. */
| 'working'
/** A turn finished while the panel was hidden: there is something to read. */
| 'done'
/** Work is running somewhere the user cannot see it (durable runs, later). */
| 'detached'
export interface AgentStatus {
activity: AgentActivity
/** What the agent is doing, already human-readable, e.g. "Bokar…". */
step: string | null
/** True while the panel showing this work is on screen. */
visible: boolean
}
export type AgentStatusEvent =
| { type: 'turn_start' }
| { type: 'step'; label: string }
| { type: 'turn_end' }
| { type: 'visibility'; visible: boolean }
/** Reserved for durable runs; v1 never dispatches it. */
| { type: 'detached'; label?: string }
/** Session ended (panel closed): forget everything. */
| { type: 'reset' }
export const INITIAL_AGENT_STATUS: AgentStatus = {
activity: 'idle',
step: null,
visible: true,
}
export function reduceAgentStatus(state: AgentStatus, event: AgentStatusEvent): AgentStatus {
switch (event.type) {
case 'turn_start':
return { ...state, activity: 'working', step: null }
case 'step':
// A step can arrive for work that started before this channel was
// listening (a resumed stream), so it implies working rather than
// requiring it.
return { ...state, activity: 'working', step: event.label }
case 'turn_end':
// Finishing on screen is not news: the answer is right there. Finishing
// behind a collapsed panel is the whole point of this channel.
return state.visible
? { ...state, activity: 'idle', step: null }
: { ...state, activity: 'done', step: null }
case 'visibility':
if (!event.visible) return { ...state, visible: false }
// Coming back into view consumes the notification, but must not cancel
// work that is still running.
return {
...state,
visible: true,
activity: state.activity === 'done' ? 'idle' : state.activity,
step: state.activity === 'done' ? null : state.step,
}
case 'detached':
return { ...state, activity: 'detached', step: event.label ?? null }
case 'reset':
return { ...INITIAL_AGENT_STATUS, visible: state.visible }
default:
return state
}
}
/**
* The trigger's label for a session that is hidden but alive.
*
* Returns null when the status has nothing to add, so the caller keeps its
* ordinary "Fråga X" / "Fortsätt med X" wording instead of this function
* inventing a second copy of it.
*/
export function collapsedStatusLabel(status: AgentStatus, name: string): string | null {
switch (status.activity) {
case 'working':
case 'detached':
return status.step ? stripEllipsis(status.step) : `${name} arbetar`
case 'done':
return `${name} är klar`
default:
return null
}
}
/**
* Tool labels are written as live narration ("Bokar avskrivningar…") because
* they sit in the message trace. On the pill the same text is a state, and the
* trailing ellipsis reads as a second spinner next to the real one.
*/
function stripEllipsis(label: string): string {
return label.replace(/[….]+$/, '').trim()
}