Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
766 changes: 10 additions & 756 deletions docs/CHANGELOG.md

Large diffs are not rendered by default.

83 changes: 83 additions & 0 deletions docs/adr/0006-in-context-background-stream-surfacing.md
Original file line number Diff line number Diff line change
@@ -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).
1 change: 1 addition & 0 deletions docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
51 changes: 51 additions & 0 deletions web-ui/app/_components/ChatTabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand Down Expand Up @@ -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(
Comment thread
Weegy marked this conversation as resolved.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Still open from my 2026-07-03 review. tabStreamState here, streamDotClass/streamAriaKey below, and the done-only dismiss in chat/page.tsx:454-458 are pure functions encoding the subtlest part of this change, and none of them has a test. Confirmed at head: grep -rn "ChatTabs\|tabStreamState\|streamDoneAria" web-ui matches only this file and the two message catalogues.

This isn't a hypothetical bar. The house convention covers exactly this: app/_components/__tests__/StreamRunner.test.tsx renders a real <StreamStoreProvider>, captures the live context value, drives it and asserts the terminal record — for the sibling component that produces the very records this file now consumes. Its header comment says it exists because #403 was a state-transition bug. The chat/page.tsx:456 defect I flagged in this review is the same class of bug, and it reproduces in about 40 lines using that harness plus renderWithIntl.

Please add a test covering the matrix before merge:

  • active tab + any phase → no dot; no record → no dot
  • each of pending / thinking / streaming / tool_running → running dot
  • done → done dot; error → error dot; abortedno dot (the intentional gap, currently protected by nothing)
  • editing suppresses the dot (:212)
  • the correct aria key per state
  • both dismiss transitions: background-done clears on select, and a foreground-done must not re-flag on switch-away

tabStreamState / streamDotClass / streamAriaKey are module-private, so either export them for a pure-logic test (cheapest, matches app/_lib/__tests__/ style) or drive them through the component.

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)]`;
Comment thread
Weegy marked this conversation as resolved.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Still open from my 2026-07-03 review — and the spec is stricter than this code assumes. byte5ai/omadia-ui is public, so I read the real text rather than the summary. §8 (docs/visual-spec.md:1589-1590):

Colour as sole signal: forbidden. Every state communicated through colour also carries a text label, an icon, or both.

An aria-label is neither — it's invisible — and title needs a hover, so it never fires on touch or keyboard. §8's sibling bullets are contrast, focus rings, hit targets, reduced motion: a perceivable-by-sight floor. This is also WCAG 2.2 SC 1.4.1 (Level A), which §8:1580 claims conformance to. What actually differs here:

state classes
running size-1.5 rounded-full bg-[var(--accent)] animate-pulse
done size-1.5 rounded-full bg-[var(--accent)]
error size-1.5 rounded-full bg-[var(--danger)]

Same size, same shape, no border, no glyph. Two independent collapses, both provable from this repo:

(a) running vs done are pixel-identical under reduced motion. app/globals.css:813-822 is a universal reset (animation-duration: 0.01ms !important; animation-iteration-count: 1 !important). It correctly covers animate-pulse — which means the pulse finishes instantly back at opacity: 1, and the only thing separating "still running" from "finished" is gone.

(b) done vs error are hue-only, and on Atelier + dark the spec's own escape hatch doesn't apply. The spec anticipates the tight separation at §2.6:477-478 and argues "L+C separation takes over there (error at L 0.45 hue 25 vs Atelier at L 0.57 hue 50…)" — but that is stated for light mode. In dark mode, by the spec's own tables:

state.error.fg  dark  OKLCH 0.75 0.12 25   (visual-spec.md:464)
Atelier accent  dark  OKLCH 0.76 0.12 60   (visual-spec.md:350)

Identical chroma, lightness within 0.01 — hue is the only separator, and hue is exactly what a red-green CVD user cannot use. Atelier is user-selectable (app/_lib/uiPrefs.ts). A user with both reduced motion and deuteranopia sees all three states as the same dot.

(c) Separately, this line violates §2.6. --danger--state-error-fg (theme.css:142,77), and the spec marks that token "Error text (never as pill bg)" (visual-spec.md:453) under "Intentionally text-only — never filled pills, badges or block fills" (:444-445). A filled 6px circle is a block fill. §10:1621-1622 makes §2 token semantics authoritative.

(d) The component being deleted was compliant. StreamToasts rendered a distinct glyph per phase — done, error, aborted, running — plus a visible text label via phaseLabelFor. This is a regression against it.

Please give each terminal state a visible non-colour cue and tint a glyph instead of filling. lucide-react is already a dependency, and §2.12:739-743 sanctions a monochrome single-stroke line glyph:

{state === 'running' && <span className="ml-1 inline-block size-1.5 shrink-0 animate-pulse rounded-full bg-[color:var(--accent)]" />}
{state === 'done'    && <Check size={12} className="ml-1 shrink-0 text-[color:var(--success)]" />}
{state === 'error'   && <AlertTriangle size={12} className="ml-1 shrink-0 text-[color:var(--danger)]" />}

That resolves (a), (b) and (c) in one change. alert-triangle is also what §7.4:1539 prescribes for the error idiom, and DevJobPhaseRail.tsx:171-173 is the in-house precedent for a compact glyph+colour+title status rail.

if (state === 'running')
return `${base} bg-[color:var(--accent)] animate-pulse`;
return `${base} bg-[color:var(--accent)]`;
}

interface TabProps {
session: ChatSession;
active: boolean;
Expand All @@ -94,6 +132,11 @@ function Tab({
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(session.title);
const inputRef = useRef<HTMLInputElement>(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) {
Expand Down Expand Up @@ -166,6 +209,14 @@ function Tab({
) : (
<span className="max-w-[18ch] truncate">{session.title}</span>
)}
{streamState && !editing && (
<span
role="img"
aria-label={t(streamAriaKey(streamState), { title: session.title })}
title={t(streamAriaKey(streamState), { title: session.title })}
className={streamDotClass(streamState)}
/>
)}
{canClose && !editing && (
<button
type="button"
Expand Down
Loading
Loading