From 9665ed4961d6a25c43c9ba53a1caa4865d93e4da Mon Sep 17 00:00:00 2001 From: Sophie Neumann Date: Thu, 2 Jul 2026 16:19:38 +0200 Subject: [PATCH] feat(chat): surface background streams as per-tab status dots --- docs/CHANGELOG.md | 11 + ...-in-context-background-stream-surfacing.md | 83 ++++ docs/adr/README.md | 1 + web-ui/app/_components/ChatTabs.tsx | 51 +++ web-ui/app/_components/StreamToasts.tsx | 427 ------------------ web-ui/app/chat/page.tsx | 16 +- web-ui/app/layout.tsx | 10 +- web-ui/messages/de.json | 24 +- web-ui/messages/en.json | 24 +- 9 files changed, 173 insertions(+), 474 deletions(-) create mode 100644 docs/adr/0006-in-context-background-stream-surfacing.md delete mode 100644 web-ui/app/_components/StreamToasts.tsx diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 738da7df..3a4e63d1 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -41,6 +41,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 translates through `llmProviderSeam`, including streaming final-event usage telemetry and provider-based retry classification. +### Changed — background chat streams surface in-context, not as toasts + +- **Removed `StreamToasts`** (the bottom-right floating cards for background + chat turns). Per the Lume visual spec §7.6, toasts / floating notifications + are a ship-blocking anti-pattern; §7.4 makes the chat the surface of record. +- **Background-stream state now lives on the chat tab**: a running background + turn shows a pulsing accent dot, a finished one an accent dot, an errored one + a danger dot — each carrying an aria-label + title so colour is never the sole + signal (§8). Selecting the tab clears its unread marker; active-session errors + continue to render inline on the turn. See [ADR-0006](adr/0006-in-context-background-stream-surfacing.md). + --- ## [0.2.0] — 2026-06-05 diff --git a/docs/adr/0006-in-context-background-stream-surfacing.md b/docs/adr/0006-in-context-background-stream-surfacing.md new file mode 100644 index 00000000..03c588e7 --- /dev/null +++ b/docs/adr/0006-in-context-background-stream-surfacing.md @@ -0,0 +1,83 @@ +# 0006 — In-context surfacing for background chat streams (no toasts) + +## Status + +Accepted + +- **Date:** 2026-07-02 +- **Deciders:** Operator-UI maintainers +- **Supersedes:** — + +## Context and Problem Statement + +The operator web UI runs chat turns per session and lets the user switch tabs +or menu routes while a turn is still streaming (the runner is headless). The +first Lume integration (#284) shipped `StreamToasts` — bottom-right floating +cards — to tell the user a background turn had progressed or finished. But the +Lume visual spec (`byte5ai/omadia-ui` `docs/visual-spec.md`) forbids toasts: +§7.4 makes the canvas the *surface of record* ("errors live in the tree, in +context") and §7.6 lists "toasts / floating notifications" as a ship-blocking +anti-pattern. #284 restyled the toasts in Lume material but deliberately left +the mechanism in place, deferring the architecture call to #286. How should +background-stream state reach the user without violating §7.6? + +## Decision Drivers + +- §7.6 anti-patterns are ship-blockers (spec §10); reintroducing them must not ship. +- §7.4 intent: state lives in context, on the surface of record — here, the chat. +- The needed data already exists per-session in `streamStore` (`phase`, + `previewTail`, `error`); no new persistence should be required. +- §8 accessibility floor: colour is never the sole signal. + +## Considered Options + +- **A — In-context surfacing.** Remove `StreamToasts`; surface background-stream + state on the session's chat tab (a dot) and keep errors inline in the turn. +- **B — Sanctioned deviation.** Keep the toasts; document that the operator UI + is not the canvas renderer the spec primarily governs (§9 scope), so the + §7.6 ban does not bind it. + +## Decision Outcome + +Chosen option: **A**, because it honours the spec's intent rather than carving +an exception, and it is cheap — `streamStore` already holds everything the tab +needs, so the change is a relocation of existing state, not new machinery. + +### Consequences + +- 🟢 **Good:** No §7.6 violation; the tab is the surface of record for its session. +- 🟢 **Good:** No new store surface, persistence, or notification centre — + `useStreamRecord` + the existing `dismiss()` cover display and clear-on-select. +- 🟢 **Good:** Active-session errors already render inline on the message, so no + new error UI was needed. +- 🔴 **Bad:** Stopping a *background* stream now takes one extra click — open the + tab, then use the existing in-chat stop button. The toast's inline abort is gone. +- ⚪ **Neutral:** The unread dot clears on tab select only. Select forgets a + `done` record; `error` / `aborted` / running records are kept — the + agent_unavailable recovery banner and inline error read them off the store, + and dropping a running record would flip `isActive` off (stop button, + composer lock). An unresolved background error therefore re-flags its dot if + the user views then leaves the tab again, which is intended. + +## Pros and Cons of the Options + +### A — In-context surfacing + +- 🟢 Matches §7.4/§7.6; no exception to defend or re-litigate later. +- 🟢 Reuses existing state; small, reviewable diff. +- 🔴 A dot is quieter than a floating card — the user must glance at the tab strip. + +### B — Sanctioned deviation + +- 🟢 Zero code change; toasts already Lume-styled. +- 🔴 Leans on a scope loophole the issue author themselves argued against; erodes + the anti-pattern list and invites the next deviation. + +## More Information + +- Issue #286; base Lume integration #284; adoption tracking #282. +- Lume visual spec §7.4 (Errors / surface of record), §7.6 (anti-pattern list), + §8 (accessibility floor) — `byte5ai/omadia-ui` `docs/visual-spec.md`. +- Implementation: `web-ui/app/_components/ChatTabs.tsx` (tab dot), + `web-ui/app/chat/page.tsx` (`handleSelect` clear-on-select), + `web-ui/app/layout.tsx` (toast mount removed). diff --git a/docs/adr/README.md b/docs/adr/README.md index 79e0e0b6..0d686e1c 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -33,6 +33,7 @@ decision, write a new ADR and mark the old one **Superseded by …**. | 0003 | [Capability-based, multi-provider middleware](0003-capability-based-multi-provider-middleware.md) | Accepted | 2026-06-03 | | 0004 | [Knowledge graph as the agent memory substrate](0004-knowledge-graph-as-memory-substrate.md) | Accepted | 2026-06-03 | | 0005 | [Two-phase confirmation for write-capable connectors](0005-two-phase-confirmation-for-writes.md) | Accepted | 2026-06-03 | +| 0006 | [In-context surfacing for background chat streams (no toasts)](0006-in-context-background-stream-surfacing.md) | Accepted | 2026-07-02 | > These first records are written *retroactively* — they document decisions that > were already implemented and proven in the product. New decisions should be diff --git a/web-ui/app/_components/ChatTabs.tsx b/web-ui/app/_components/ChatTabs.tsx index 568e26a0..13ff94cd 100644 --- a/web-ui/app/_components/ChatTabs.tsx +++ b/web-ui/app/_components/ChatTabs.tsx @@ -9,6 +9,11 @@ import { } from 'react'; import { useTranslations } from 'next-intl'; import type { ChatSession } from '../_lib/chatSessions'; +import { + isStreamActive, + useStreamRecord, + type StreamRecord, +} from '../_lib/streamStore'; interface ChatTabsProps { sessions: ChatSession[]; @@ -71,6 +76,39 @@ export function ChatTabs({ ); } +/** Background-stream state a tab surfaces as a dot. `null` = nothing to show + * (active tab, no record, or a user-aborted turn). */ +type TabStreamState = 'running' | 'done' | 'error'; + +function tabStreamState( + active: boolean, + rec: StreamRecord | undefined, +): TabStreamState | null { + if (active || !rec) return null; + if (isStreamActive(rec)) return 'running'; + if (rec.phase === 'error') return 'error'; + if (rec.phase === 'done') return 'done'; + return null; +} + +function streamAriaKey(state: TabStreamState): string { + return state === 'running' + ? 'streamRunningAria' + : state === 'error' + ? 'streamErrorAria' + : 'streamDoneAria'; +} + +/** A dot, never a status pill (§7.6). Colour is never the sole signal — + * each dot carries an aria-label + title (§8). */ +function streamDotClass(state: TabStreamState): string { + const base = 'ml-1 inline-block size-1.5 shrink-0 rounded-full'; + if (state === 'error') return `${base} bg-[color:var(--danger)]`; + if (state === 'running') + return `${base} bg-[color:var(--accent)] animate-pulse`; + return `${base} bg-[color:var(--accent)]`; +} + interface TabProps { session: ChatSession; active: boolean; @@ -94,6 +132,11 @@ function Tab({ const [editing, setEditing] = useState(false); const [draft, setDraft] = useState(session.title); const inputRef = useRef(null); + // In-context surfacing (issue #286): a background session's stream state + // lives on its tab, not in a floating toast. The active tab shows nothing — + // its stream is already visible inline. `aborted` gets no marker: the user + // stopped it themselves, so there's nothing unread to flag. + const streamState = tabStreamState(active, useStreamRecord(session.id)); useEffect(() => { if (editing) { @@ -166,6 +209,14 @@ function Tab({ ) : ( {session.title} )} + {streamState && !editing && ( + + )} {canClose && !editing && ( - - {record.previewTail && ( -
- {record.previewTail} -
- )} -
- {t('elapsedSec', { sec: elapsedSec })} - {typeof record.tokensIn === 'number' && record.tokensIn > 0 ? ( - - · ↓ {formatTokenCount(record.tokensIn)} - - ) : null} - {typeof record.tokensOut === 'number' && record.tokensOut > 0 ? ( - - ↑ {formatTokenCount(record.tokensOut)} - - ) : null} - {typeof record.cacheTokens === 'number' && record.cacheTokens > 0 ? ( - - · 🟢 {formatTokenCount(record.cacheTokens)} - - ) : null} - {record.error ? ( - - · {record.error} - - ) : null} -
- - - {/* Real-stop affordance — only while the stream is still running. - Clicking opens a confirm modal; confirming calls onAbort(), which - aborts the underlying fetch. Separate from the top-right X, which - only hides the toast. */} - {!isTerminal ? ( - - ) : null} - - {confirming ? ( - { - setConfirming(false); - onAbort(); - }} - onCancel={() => { - setConfirming(false); - }} - /> - ) : null} - - - ); -} - -interface AbortConfirmModalProps { - t: ReturnType; - onConfirm: () => void; - onCancel: () => void; -} - -/** - * Lightweight custom confirmation dialog (no native window.confirm). Rendered - * through a portal to so its fixed positioning is immune to the - * transformed motion ancestors of the toast stack. Closes on Escape or - * backdrop click; the safe "keep running" action is auto-focused. - */ -function AbortConfirmModal({ - t, - onConfirm, - onCancel, -}: AbortConfirmModalProps): React.ReactPortal | null { - useEffect(() => { - const onKey = (e: KeyboardEvent): void => { - if (e.key === 'Escape') onCancel(); - }; - window.addEventListener('keydown', onKey); - return () => { - window.removeEventListener('keydown', onKey); - }; - }, [onCancel]); - - if (typeof document === 'undefined') return null; - - return createPortal( - { - e.stopPropagation(); - onCancel(); - }} - role="presentation" - > - { - e.stopPropagation(); - }} - > -

- {t('abortConfirmTitle')} -

-

- {t('abortConfirmBody')} -

-
- - -
-
-
, - document.body, - ); -} - -/** "1.2k" / "340" / "1.45M" — keeps the toast row compact. */ -function formatTokenCount(n: number): string { - if (n < 1000) return String(n); - if (n < 10000) return (n / 1000).toFixed(2).replace(/\.?0+$/, '') + 'k'; - if (n < 1000000) return Math.round(n / 1000) + 'k'; - return (n / 1000000).toFixed(2).replace(/\.?0+$/, '') + 'M'; -} - -/** Re-renders the caller every second when `active` is true. Returns the - * current wall-clock at the last tick. */ -function useTickingNow(active: boolean): number { - const [now, setNow] = useState(() => Date.now()); - useEffect(() => { - if (!active) return; - const id = setInterval(() => { - setNow(Date.now()); - }, 1000); - return () => { - clearInterval(id); - }; - }, [active]); - return now; -} - -function phaseLabelFor( - phase: StreamPhase, - t: ReturnType, -): string { - switch (phase) { - case 'pending': - case 'thinking': - return t('phaseThinking'); - case 'streaming': - return t('phaseStreaming'); - case 'tool_running': - return t('phaseToolRunning'); - case 'done': - return t('phaseDone'); - case 'error': - return t('phaseError'); - case 'aborted': - return t('phaseAborted'); - } -} - -function paletteFor(phase: StreamPhase): { - border: string; - bg: string; - icon: string; - symbol: string; -} { - switch (phase) { - case 'done': - return { - border: 'border-[color:var(--success)]', - bg: 'bg-[color:var(--success)]/10', - icon: 'text-[color:var(--success)]', - symbol: '✓', - }; - case 'error': - return { - border: 'border-[color:var(--danger-edge)]', - bg: 'bg-[color:var(--danger)]/8', - icon: 'text-[color:var(--danger)]', - symbol: '✗', - }; - case 'aborted': - return { - border: 'border-[color:var(--border)]', - bg: 'bg-[color:var(--bg-soft)]', - icon: 'text-[color:var(--fg-muted)]', - symbol: '⏹', - }; - default: - return { - border: 'border-[color:var(--accent)]', - bg: 'bg-[color:var(--bg-elevated)]', - icon: 'text-[color:var(--accent)]', - symbol: '…', - }; - } -} diff --git a/web-ui/app/chat/page.tsx b/web-ui/app/chat/page.tsx index 5503cac9..6ad6c2ea 100644 --- a/web-ui/app/chat/page.tsx +++ b/web-ui/app/chat/page.tsx @@ -430,6 +430,20 @@ export default function ChatPage(): React.ReactElement { void deleteSession(id); }; + // Selecting a tab marks its background answer as read (issue #286). We only + // forget a `done` record: the dot itself already hides once the tab is active + // (it renders for non-active tabs only), so dismiss exists purely to stop a + // finished answer re-flagging after the user switches away again. `error` / + // `aborted` records are deliberately kept — the agent_unavailable recovery + // banner and the inline error read them off the store (page.tsx §Phase A). + // A running record is likewise kept so `isActive` (stop button / composer + // lock) stays correct once the tab is active. + const handleSelect = (id: string): void => { + const rec = streamStore.get(id); + if (rec?.phase === 'done') streamStore.dismiss(id); + setActive(id); + }; + const canReset = !hydrating && !resetPending && activeSession.messages.length > 0; @@ -438,7 +452,7 @@ export default function ChatPage(): React.ReactElement { { diff --git a/web-ui/app/layout.tsx b/web-ui/app/layout.tsx index 8603cc34..0e28f611 100644 --- a/web-ui/app/layout.tsx +++ b/web-ui/app/layout.tsx @@ -12,7 +12,6 @@ import { Nav } from './_components/Nav'; import { ThemeControls } from './_components/ThemeControls'; import { SessionWatcher } from './_components/SessionWatcher'; import { StreamRunner } from './_components/StreamRunner'; -import { StreamToasts } from './_components/StreamToasts'; import { ChatSessionsProvider } from './_lib/chatSessionsContext'; import { StreamStoreProvider } from './_lib/streamStore'; import { UI_PREFS_COOKIE, parseUiPrefsCookie } from './_lib/uiPrefs'; @@ -123,12 +122,11 @@ export default async function RootLayout({
{children}
- {/* Background-stream toasts (only render for chats that - aren't currently in view). The runner is headless — it - owns the fetch + NDJSON-parse loop so that switching to - another menu route doesn't kill an in-flight turn. */} + {/* Headless stream runner — owns the fetch + NDJSON-parse loop + so switching menu route doesn't kill an in-flight turn. + Background-stream state surfaces in-context on the chat tab + (issue #286, Lume §7.4/§7.6), not in a floating toast. */} - diff --git a/web-ui/messages/de.json b/web-ui/messages/de.json index 54b27c92..a3e5b788 100644 --- a/web-ui/messages/de.json +++ b/web-ui/messages/de.json @@ -607,7 +607,10 @@ "tabTitleSuffix": "ID: {id}\nDoppelklick zum Umbenennen", "closeAriaLabel": "Tab schließen: {title}", "closeWhileBusyTitle": "Warten bis der Turn durch ist", - "closeTitle": "Tab schließen" + "closeTitle": "Tab schließen", + "streamRunningAria": "{title}: Antwort wird erstellt", + "streamDoneAria": "{title}: Antwort fertig", + "streamErrorAria": "{title}: Antwort fehlgeschlagen" }, "authBadge": { "loginLink": "Login", @@ -954,25 +957,6 @@ "orphanExplain": "Plugin-IDs aus früheren Seeds, die im aktuellen Katalog nicht mehr existieren (typisch: Umbenennung de.byte5.agent.* → @omadia/*). Werden beim nächsten „Speichern\" verworfen, außer du markierst sie als „Behalten\".", "orphanKeep": "Behalten" }, - "streamToasts": { - "phaseThinking": "Agent denkt", - "phaseStreaming": "Antwort läuft", - "phaseToolRunning": "Tool läuft", - "phaseDone": "Antwort fertig", - "phaseError": "Fehler", - "phaseAborted": "Abgebrochen", - "elapsedSec": "{sec}s", - "tokensInTitle": "{n} Input-Tokens (kumuliert über Iterationen)", - "tokensOutTitle": "{n} Output-Tokens (kumuliert über Iterationen)", - "cacheHitTitle": "{n} Cache-Read-Tokens", - "dismissAriaLabel": "Benachrichtigung schließen (Stream läuft weiter)", - "dismissTitle": "Schließen", - "abortButton": "Stream stoppen", - "abortConfirmTitle": "Stream wirklich stoppen?", - "abortConfirmBody": "Die laufende Antwort wird sofort abgebrochen und kann nicht fortgesetzt werden.", - "abortConfirmKeep": "Weiter laufen lassen", - "abortConfirmStop": "Ja, stoppen" - }, "builder": { "issueReport": { "kicker": "Core-Bug an omadia melden", diff --git a/web-ui/messages/en.json b/web-ui/messages/en.json index 77bc18f1..c7736233 100644 --- a/web-ui/messages/en.json +++ b/web-ui/messages/en.json @@ -607,7 +607,10 @@ "tabTitleSuffix": "ID: {id}\nDouble-click to rename", "closeAriaLabel": "Close tab: {title}", "closeWhileBusyTitle": "Wait until the turn is done", - "closeTitle": "Close tab" + "closeTitle": "Close tab", + "streamRunningAria": "{title}: response in progress", + "streamDoneAria": "{title}: response ready", + "streamErrorAria": "{title}: response failed" }, "authBadge": { "loginLink": "Login", @@ -954,25 +957,6 @@ "orphanExplain": "Plugin ids from earlier seeds that no longer exist in the current catalog (typically a rename like de.byte5.agent.* → @omadia/*). Dropped on next Save unless you tick \"Keep\".", "orphanKeep": "Keep" }, - "streamToasts": { - "phaseThinking": "Agent is thinking", - "phaseStreaming": "Answer streaming", - "phaseToolRunning": "Tool running", - "phaseDone": "Answer ready", - "phaseError": "Error", - "phaseAborted": "Aborted", - "elapsedSec": "{sec}s", - "tokensInTitle": "{n} input tokens (cumulative across iterations)", - "tokensOutTitle": "{n} output tokens (cumulative across iterations)", - "cacheHitTitle": "{n} cache-read tokens", - "dismissAriaLabel": "Dismiss notification (stream keeps running)", - "dismissTitle": "Dismiss", - "abortButton": "Stop stream", - "abortConfirmTitle": "Stop this stream?", - "abortConfirmBody": "The running response will be aborted immediately and can't be resumed.", - "abortConfirmKeep": "Keep running", - "abortConfirmStop": "Yes, stop" - }, "builder": { "issueReport": { "kicker": "Report core bug to omadia",