diff --git a/DECISIONS.md b/DECISIONS.md index 5b97bf4e..40c9144d 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -572,3 +572,6 @@ One line per decision: `[YYYY-MM-DD] : `. 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
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. diff --git a/app/(dashboard)/layout.tsx b/app/(dashboard)/layout.tsx index 38a1c9c4..3c2d6170 100644 --- a/app/(dashboard)/layout.tsx +++ b/app/(dashboard)/layout.tsx @@ -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, diff --git a/app/globals.css b/app/globals.css index cf634f76..433eabfc 100644 --- a/app/globals.css +++ b/app/globals.css @@ -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%; diff --git a/components/agent/AgentChat.tsx b/components/agent/AgentChat.tsx index af33b256..6a30e065 100644 --- a/components/agent/AgentChat.tsx +++ b/components/agent/AgentChat.tsx @@ -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(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(null) const scrollerRef = useRef(null) const textareaRef = useRef(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, diff --git a/components/agent/AgentSheet.tsx b/components/agent/AgentSheet.tsx index 2af3ab2d..a461d731 100644 --- a/components/agent/AgentSheet.tsx +++ b/components/agent/AgentSheet.tsx @@ -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} /> ) : ( setConversationId(id)} + onStatus={onStatus} /> )} diff --git a/components/agent/AgentSheetProvider.tsx b/components/agent/AgentSheetProvider.tsx index b9fb0182..c886243e 100644 --- a/components/agent/AgentSheetProvider.tsx +++ b/components/agent/AgentSheetProvider.tsx @@ -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(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
+ // 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} diff --git a/components/agent/AgentTrigger.tsx b/components/agent/AgentTrigger.tsx index 5e0683ac..0f819532 100644 --- a/components/agent/AgentTrigger.tsx +++ b/components/agent/AgentTrigger.tsx @@ -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" > - + + + {/* An answer waiting behind a hidden panel is the one thing worth a + dot: it is unread, not in progress. */} + {finished && ( + + )} + + {working && collapsed && ( + + )} {labelText} ) diff --git a/components/agent/__tests__/agent-status.test.ts b/components/agent/__tests__/agent-status.test.ts new file mode 100644 index 00000000..7ddb9ada --- /dev/null +++ b/components/agent/__tests__/agent-status.test.ts @@ -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') + }) +}) diff --git a/components/agent/agent-status.ts b/components/agent/agent-status.ts new file mode 100644 index 00000000..756da806 --- /dev/null +++ b/components/agent/agent-status.ts @@ -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() +}