feat(assistant): dock the panel into the frame, give the trigger a status channel (#1220)

* 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>
This commit is contained in:
Jakob Wennberg
2026-07-27 10:57:22 +02:00
committed by GitHub
co-authored by Claude Opus 5
parent 16e1a84b4b
commit 60cc51fe21
9 changed files with 396 additions and 17 deletions
+3
View File
@@ -572,3 +572,6 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-07-27] Kept the '********-1234' mask sentinel in UpdateCustomerSchema against the swarm's V4.5 suggestion to strip it in the route pre-validation: the mask shape (asterisks) can never collide with a valid personnummer, CreateCustomerSchema stays strict so the sentinel only exists where a stored value exists to preserve, and moving the strip to the route would split the contract across two files for zero behavioral change.
[2026-07-27] Restored 20260726140000 to its preview-recorded content (60b14193) and restated the NULL-safe tenant guard as 20260727130000: the PR #1215 preview branch recorded that version before review hardening edited it, an applied version never re-runs, and a bare rename would have orphaned the preview's version row; CREATE OR REPLACE makes both replay orders converge on identical prosrc.
[2026-07-27] Voucher-link guard conflict, final resolution across the two parallel sessions: 20260726140000 is kept byte-identical to HEAD (NULL-safe caller_is_company_member form) instead of being reverted in place to the raw NOT-IN shape, because the guard edit landed pre-publish (60b14193 at 02:08, PR #1215 opened 02:14) so the preview most plausibly recorded the NULL-safe content, and reintroducing the ratchet-banned raw shape into a migration file was the costlier wrong-guess; 20260727130000 stays, restating the same final bodies via CREATE OR REPLACE, which converges preview, prod-at-merge and CI replays under either hypothesis about what the preview actually recorded.
[2026-07-27] Sonnet 5 no-thinking output ceiling kept separate (MAX_TOKENS_NO_THINKING 5400): max_tokens now caps thinking and reply together, so one shared ceiling would have quadrupled what a non-thinking intent may emit; 5400 is the old 4096 scaled for the new tokenizer.
[2026-07-27] Assistant panel docks via a --agent-dock-w CSS custom property rather than restructuring the frame: the dashboard layout is a server component, so a client-owned class on <main> would have meant lifting it to a client boundary for a margin.
[2026-07-27] Agent status is a reducer in a React-free module with an unused 'detached' state: durable background runs must publish to the same channel later (plan seam 8.6), and this repo's unit project is node-only so the state machine has to be testable without a component harness.
+2 -2
View File
@@ -38,9 +38,9 @@ const NO_COMPANY_ALLOWED_PATHS = ['/settings/account']
*/
const MAIN_PANEL_CLASS =
'safe-area-main-padding md:!pb-0 relative bg-background min-h-screen ' +
'md:min-h-0 md:ml-[var(--nav-w)] md:mt-[10px] md:mr-[10px] md:h-[calc(100vh-20px)] ' +
'md:min-h-0 md:ml-[var(--nav-w)] md:mt-[10px] md:mr-[var(--agent-dock-w)] md:h-[calc(100vh-20px)] ' +
'md:overflow-y-auto md:rounded-xl md:border md:border-border ' +
'md:transition-[margin-left] md:duration-300 md:ease-[cubic-bezier(0.32,0.72,0,1)]'
'md:transition-[margin-left,margin-right] md:duration-300 md:ease-[cubic-bezier(0.32,0.72,0,1)]'
export default async function DashboardLayout({
children,
+5
View File
@@ -19,6 +19,11 @@
variable so they stay in lockstep. */
--nav-w: 248px;
/* Width the docked assistant panel claims from the page panel. Default is
the plain frame gutter, so the layout is correct before any JS runs; the
agent provider raises it while the panel is docked and clears it again. */
--agent-dock-w: 10px;
/* Grayscale Chrome Palette: paper white + warm beige */
--background: 0 0% 100%;
+22
View File
@@ -24,6 +24,7 @@ import { UpgradeNote } from '@/components/billing/UpgradeNote'
import ApprovalCard from './ApprovalCard'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
import type { StoredStagedOperation } from '@/types'
import type { AgentStatusEvent } from './agent-status'
// Markdown parser loads separately from the chat surface: react-markdown +
// remark-gfm pull in the whole unified/remark tree.
@@ -166,6 +167,9 @@ export interface AgentChatProps {
// Optional vertical padding override: defaults to py-6 inside the
// scroller. The full-page chat uses py-8 for breathing room.
scrollerClassName?: string
// Publishes turn boundaries and the current tool to the shared status
// channel. Optional: /chat is its own surface and has nothing to notify.
onStatus?: (event: AgentStatusEvent) => void
// Pre-baked first user message. When set, the mount effect fires the first
// turn with this verbatim (skipping the intent's promptTemplate path) AND
// renders it as a user-side message in the timeline. Used by /chat empty
@@ -183,6 +187,7 @@ export default function AgentChat({
onFirstTurnComplete,
scrollerClassName,
seedUserMessage,
onStatus,
}: AgentChatProps) {
const [conversationId, setConversationId] = useState<string | null>(initialConversationId ?? null)
// Track whether the first-turn callback has fired so the bootstrap
@@ -194,6 +199,20 @@ export default function AgentChat({
const hasAi = useCapability(CAPABILITY.ai)
const [input, setInput] = useState('')
const [streaming, setStreaming] = useState(false)
// Turn boundaries for the status channel are derived from the streaming flag
// rather than published at each call site: a turn can end by completing,
// erroring, aborting or being stopped, and a channel that misses one of
// those leaves the trigger claiming the agent is still working forever.
const turnOpenRef = useRef(false)
useEffect(() => {
if (streaming) {
turnOpenRef.current = true
onStatus?.({ type: 'turn_start' })
} else if (turnOpenRef.current) {
turnOpenRef.current = false
onStatus?.({ type: 'turn_end' })
}
}, [streaming, onStatus])
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const scrollerRef = useRef<HTMLDivElement>(null)
const textareaRef = useRef<HTMLTextAreaElement>(null)
@@ -565,6 +584,9 @@ export default function AgentChat({
case 'tool_use':
// Next text_delta should open a fresh paragraph.
breakBeforeNextTextRef.current = true
// Same label the in-thread chip shows, so a hidden panel and a visible
// one describe the step identically.
onStatus?.({ type: 'step', label: prettyToolName(ev.name as string) })
setMessages((prev) =>
updateLastAssistant(prev, (m) => ({
...m,
+27
View File
@@ -8,6 +8,7 @@ import AgentChat, {
type ChatMessage,
} from './AgentChat'
import type { StoredStagedOperation } from '@/types'
import type { AgentStatusEvent } from './agent-status'
import AgentAvatar from './AgentAvatar'
import AgentSessionList from './AgentSessionList'
import SandboxAgentPreview from './SandboxAgentPreview'
@@ -30,11 +31,23 @@ interface Props {
// Hidden (display:none) but still mounted so the conversation survives. The
// provider keeps rendering this component; we just visually remove it.
collapsed: boolean
// Publishes what the agent is doing to the one status channel, so the
// floating trigger can say "arbetar" / "är klar" while the panel is hidden.
onStatus?: (event: AgentStatusEvent) => void
// How much width the panel is claiming from the page, or null while it
// overlays. Docking is what makes the panel a second column instead of a
// curtain over the thing being discussed.
onDockWidthChange?: (px: number | null) => void
onCollapse: () => void
onRestart: () => void
onClose: () => void
}
// The panel is max-w-[480px]; the page gives up that plus the frame's own
// 10px gutter, so the two panels float side by side on the frame with the
// same seam as everywhere else instead of butting their borders together.
const DOCKED_WIDTH = 490
interface LoadedConversation {
id: string
intentId: string
@@ -49,6 +62,8 @@ export default function AgentSheet({
contextRef,
seedUserMessage,
collapsed,
onStatus,
onDockWidthChange,
onCollapse,
onRestart,
onClose,
@@ -66,6 +81,16 @@ export default function AgentSheet({
// Enlarge the panel IN PLACE (no navigation): the user stays on the current
// page (e.g. /bookkeeping) with a wider reading/verifying surface.
const [expanded, setExpanded] = useState(false)
// Dock at the compact width only. Expanded is a deliberate focus mode: at
// 1100px there is no page left to read beside it, so it goes back to
// overlaying. Collapsed and mobile claim nothing (below md the panel is
// full-width and the frame layout ignores the variable anyway).
const dockWidth = collapsed || expanded ? null : DOCKED_WIDTH
useEffect(() => {
onDockWidthChange?.(dockWidth)
}, [dockWidth, onDockWidthChange])
useEffect(() => () => onDockWidthChange?.(null), [onDockWidthChange])
const { identity } = useAgentSheet()
const companyCtx = useCompanyOptional()
const isSandbox = companyCtx?.isSandbox ?? false
@@ -310,6 +335,7 @@ export default function AgentSheet({
initialConversationId={loaded.id}
initialMessages={loaded.messages}
onConversationIdChange={(id) => setConversationId(id)}
onStatus={onStatus}
/>
) : (
<AgentChat
@@ -318,6 +344,7 @@ export default function AgentSheet({
contextRef={contextRef}
seedUserMessage={seedUserMessage}
onConversationIdChange={(id) => setConversationId(id)}
onStatus={onStatus}
/>
)}
</div>
+58 -1
View File
@@ -1,7 +1,21 @@
'use client'
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useReducer,
useState,
} from 'react'
import dynamic from 'next/dynamic'
import {
INITIAL_AGENT_STATUS,
reduceAgentStatus,
type AgentStatus,
type AgentStatusEvent,
} from './agent-status'
// The sheet is a lazy chunk, and it used to have no loading state at all: a
// click on the launcher produced NOTHING until the chunk arrived, then the
@@ -134,6 +148,14 @@ interface AgentSheetContextValue {
isOpen: boolean
// True while a session exists but is minimized off-screen.
collapsed: boolean
// What the assistant is doing, for surfaces outside the panel (today the
// floating trigger). See agent-status.ts: one channel, so a durable
// background run can publish to it later without a second one.
status: AgentStatus
publishAgentStatus: (event: AgentStatusEvent) => void
// Width in px the docked panel is claiming from the page, or null when it is
// overlaying instead. Set by the panel, read by the frame layout.
setDockWidth: (px: number | null) => void
// Agent name + avatar: set once from the server-loaded agent_profile
// and exposed through context so the trigger / chat headers can render
// them without their own fetches. Null when the user hasn't verified a
@@ -157,6 +179,33 @@ export function AgentSheetProvider({ children, identity }: AgentSheetProviderPro
// Bumped by restartAgentSheet to force a fresh AgentChat mount (a new thread)
// on the same intent, without closing the sheet.
const [restartNonce, setRestartNonce] = useState(0)
const [status, publishAgentStatus] = useReducer(reduceAgentStatus, INITIAL_AGENT_STATUS)
const [dockWidth, setDockWidth] = useState<number | null>(null)
// Visibility drives whether a finished turn is news or not, so it is derived
// from the session state rather than reported by the panel: closing counts as
// hidden just like collapsing, and a panel that never opened is neither.
const panelVisible = activeArgs !== null && !collapsed
useEffect(() => {
publishAgentStatus({ type: 'visibility', visible: panelVisible })
}, [panelVisible])
// Dock the panel into the frame instead of over it: the page panel gives up
// its right margin so the content the user is asking about stays readable
// beside the answer. Written as a CSS variable rather than a class on <main>
// because the frame layout is a server component; globals.css seeds the
// default so the first paint is not a jump.
useEffect(() => {
const root = document.documentElement
if (dockWidth === null) {
root.style.removeProperty('--agent-dock-w')
return
}
root.style.setProperty('--agent-dock-w', `${dockWidth}px`)
return () => {
root.style.removeProperty('--agent-dock-w')
}
}, [dockWidth])
const openAgentSheet = useCallback((args: OpenAgentSheetArgs) => {
setActiveArgs(args)
@@ -166,6 +215,8 @@ export function AgentSheetProvider({ children, identity }: AgentSheetProviderPro
const closeAgentSheet = useCallback(() => {
setActiveArgs(null)
setCollapsed(false)
setDockWidth(null)
publishAgentStatus({ type: 'reset' })
}, [])
const collapseAgentSheet = useCallback(() => setCollapsed(true), [])
@@ -189,6 +240,9 @@ export function AgentSheetProvider({ children, identity }: AgentSheetProviderPro
restartAgentSheet,
isOpen: activeArgs !== null,
collapsed,
status,
publishAgentStatus,
setDockWidth,
identity: resolvedIdentity,
}),
[
@@ -199,6 +253,7 @@ export function AgentSheetProvider({ children, identity }: AgentSheetProviderPro
restartAgentSheet,
activeArgs,
collapsed,
status,
resolvedIdentity,
],
)
@@ -214,6 +269,8 @@ export function AgentSheetProvider({ children, identity }: AgentSheetProviderPro
contextRef={activeArgs.contextRef}
seedUserMessage={activeArgs.seedUserMessage}
collapsed={collapsed}
onStatus={publishAgentStatus}
onDockWidthChange={setDockWidth}
onCollapse={collapseAgentSheet}
onRestart={restartAgentSheet}
onClose={closeAgentSheet}
+38 -14
View File
@@ -2,7 +2,9 @@
import { useAgentSheet } from './AgentSheetProvider'
import { usePathname, useRouter } from 'next/navigation'
import { Loader2 } from 'lucide-react'
import AgentAvatar from './AgentAvatar'
import { collapsedStatusLabel } from './agent-status'
import { routeToIntent } from '@/lib/agent/intents/route-mapping'
import { useCapability } from '@/contexts/CompanyContext'
import { CAPABILITY } from '@/lib/entitlements/keys'
@@ -27,7 +29,7 @@ import { CAPABILITY } from '@/lib/entitlements/keys'
// "Fråga assistenten" in Dokumentinkorgen: both passing a transaction_id the
// pathname-only FAB can't know.)
export default function AgentTrigger({ hidden = false }: { hidden?: boolean }) {
const { openAgentSheet, expandAgentSheet, isOpen, collapsed, identity } = useAgentSheet()
const { openAgentSheet, expandAgentSheet, isOpen, collapsed, status, identity } = useAgentSheet()
const pathname = usePathname()
const router = useRouter()
const hasAi = useCapability(CAPABILITY.ai)
@@ -71,13 +73,22 @@ export default function AgentTrigger({ hidden = false }: { hidden?: boolean }) {
// AI assistant runs on a paid cloud service. Without the capability, opening
// the sheet would land the user in a chat whose send is dead. Keep the FAB
// visible (it's the conversion surface) but route it to billing instead.
const labelText = collapsed
? `Fortsätt med ${name}`
: !hasAi
? `Uppgradera för att använda ${name}`
: dispatch.labelSuffix
? `Fråga ${name} ${dispatch.labelSuffix}`
: `Fråga ${name}`
// A minimized session used to be 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. While hidden, the status channel does the talking.
const statusText = collapsed ? collapsedStatusLabel(status, name) : null
const working = status.activity === 'working' || status.activity === 'detached'
const finished = collapsed && status.activity === 'done'
const labelText =
statusText ??
(collapsed
? `Fortsätt med ${name}`
: !hasAi
? `Uppgradera för att använda ${name}`
: dispatch.labelSuffix
? `Fråga ${name} ${dispatch.labelSuffix}`
: `Fråga ${name}`)
const handleClick = () => {
// Collapsed → bring the existing session back, don't start a new one.
@@ -104,13 +115,26 @@ export default function AgentTrigger({ hidden = false }: { hidden?: boolean }) {
// no mobile nav to worry about.
className="fixed right-4 z-30 flex h-12 max-w-[calc(100vw-2rem)] items-center gap-2 rounded-full bg-foreground pl-2 pr-4 text-background shadow-lg hover:bg-foreground/90 transition-colors duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 bottom-[calc(env(safe-area-inset-bottom,0px)+5rem)] md:bottom-4"
aria-label={labelText}
// The label changes on its own while the panel is hidden, so it has to be
// announced rather than only redrawn. Polite: it never interrupts.
aria-live="polite"
>
<AgentAvatar
avatarId={identity.avatarId}
size="sm"
className="ring-2 ring-background/20 shrink-0"
alt={name}
/>
<span className="relative shrink-0">
<AgentAvatar
avatarId={identity.avatarId}
size="sm"
className="ring-2 ring-background/20"
alt={name}
/>
{/* An answer waiting behind a hidden panel is the one thing worth a
dot: it is unread, not in progress. */}
{finished && (
<span className="absolute -right-0.5 -top-0.5 h-2.5 w-2.5 rounded-full bg-emerald-500 ring-2 ring-foreground" />
)}
</span>
{working && collapsed && (
<Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin motion-reduce:animate-none" />
)}
<span className="text-sm font-medium truncate">{labelText}</span>
</button>
)
@@ -0,0 +1,120 @@
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')
})
})
+121
View File
@@ -0,0 +1,121 @@
/**
* 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()
}