-
Notifications
You must be signed in to change notification settings - Fork 6
feat(chat): surface background streams as per-tab status dots #409
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| 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). |
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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( | ||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Still open from my 2026-07-03 review. This isn't a hypothetical bar. The house convention covers exactly this: Please add a test covering the matrix before merge:
|
||||||||||
| 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)]`; | ||||||||||
|
Weegy marked this conversation as resolved.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
An
Same size, same shape, no border, no glyph. Two independent collapses, both provable from this repo: (a) (b) 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 ( (c) Separately, this line violates §2.6. (d) The component being deleted was compliant. Please give each terminal state a visible non-colour cue and tint a glyph instead of filling. {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. |
||||||||||
| 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<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) { | ||||||||||
|
|
@@ -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" | ||||||||||
|
|
||||||||||
Uh oh!
There was an error while loading. Please reload this page.