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.
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.
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