diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9f852115..feecfb6f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -85,6 +85,16 @@ jobs: -t roboco-agent-base $(regtags roboco-agent-base) . echo "::endgroup::" + # roboco-agent-grok MUST build next, ahead of the loop: the two + # interactive Grok roles (prompter, secretary) build `FROM + # roboco-agent-grok` — a local tag that has to exist in the daemon + # before they build, and bash associative-array iteration order is + # unspecified, so it can't just be another entry in IMAGES below. + echo "::group::build roboco-agent-grok" + docker build -f docker/agent-grok.Dockerfile \ + -t roboco-agent-grok $(regtags roboco-agent-grok) . + echo "::endgroup::" + # Every other image → its Dockerfile. Names mirror docker-compose's # `image:` values exactly, so compose can later pull instead of build. declare -A IMAGES=( @@ -101,7 +111,8 @@ jobs: [roboco-agent-prompter]=docker/agent-prompter.Dockerfile [roboco-agent-secretary]=docker/agent-secretary.Dockerfile [roboco-agent-pr-reviewer]=docker/agent-pr-reviewer.Dockerfile - [roboco-agent-grok]=docker/agent-grok.Dockerfile + [roboco-agent-grok-prompter]=docker/agent-grok-prompter.Dockerfile + [roboco-agent-grok-secretary]=docker/agent-grok-secretary.Dockerfile ) for name in "${!IMAGES[@]}"; do echo "::group::build ${name}" @@ -114,10 +125,13 @@ jobs: echo "::group::push roboco-agent-base" pushall roboco-agent-base echo "::endgroup::" + echo "::group::push roboco-agent-grok" + pushall roboco-agent-grok + echo "::endgroup::" for name in "${!IMAGES[@]}"; do echo "::group::push ${name}" pushall "${name}" echo "::endgroup::" done - echo "Published roboco-agent-base + ${#IMAGES[@]} more images to GHCR + Docker Hub at :${VERSION} and :latest" + echo "Published roboco-agent-base + roboco-agent-grok + ${#IMAGES[@]} more images to GHCR + Docker Hub at :${VERSION} and :latest" diff --git a/docker-compose.registry.yml b/docker-compose.registry.yml index e652a56f..44cc26ee 100644 --- a/docker-compose.registry.yml +++ b/docker-compose.registry.yml @@ -66,6 +66,31 @@ services: timeout: 5s retries: 5 + # -------------------------------------------------------------------------- + # Backup - periodic pg_dump of the roboco DB (interim; no PITR/WAL yet) + # -------------------------------------------------------------------------- + backup: + image: pgvector/pgvector:pg16 + container_name: roboco-backup + restart: unless-stopped + # data-only network — pg_dump reaches postgres by container name, same + # as every other data-network consumer; never exposed to the agent mesh. + networks: + - data + environment: + POSTGRES_HOST: roboco-postgres + POSTGRES_PORT: 5432 + POSTGRES_USER: roboco + POSTGRES_PASSWORD: roboco + POSTGRES_DB: roboco + entrypoint: ["/bin/bash", "/scripts/backup-entrypoint.sh"] + volumes: + - ./docker/scripts/backup-entrypoint.sh:/scripts/backup-entrypoint.sh:ro + - ${ROBOCO_DATA_DIR:-./data}/backups:/backups + depends_on: + postgres: + condition: service_healthy + ollama: image: ollama/ollama:latest container_name: roboco-ollama diff --git a/docker-compose.yml b/docker-compose.yml index 3fd575b6..1649c5e3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -47,6 +47,31 @@ services: timeout: 5s retries: 5 + # ========================================================================== + # Backup - periodic pg_dump of the roboco DB (interim; no PITR/WAL yet) + # ========================================================================== + backup: + image: pgvector/pgvector:pg16 + container_name: roboco-backup + restart: unless-stopped + # data-only network — pg_dump reaches postgres by container name, same + # as every other data-network consumer; never exposed to the agent mesh. + networks: + - data + environment: + POSTGRES_HOST: roboco-postgres + POSTGRES_PORT: 5432 + POSTGRES_USER: roboco + POSTGRES_PASSWORD: roboco + POSTGRES_DB: roboco + entrypoint: ["/bin/bash", "/scripts/backup-entrypoint.sh"] + volumes: + - ./docker/scripts/backup-entrypoint.sh:/scripts/backup-entrypoint.sh:ro + - ${ROBOCO_DATA_DIR:-./data}/backups:/backups + depends_on: + postgres: + condition: service_healthy + # ========================================================================== # MinIO - Object storage for rendered videos (NAS default-on; registry OFF) # ========================================================================== diff --git a/docker/scripts/backup-entrypoint.sh b/docker/scripts/backup-entrypoint.sh new file mode 100755 index 00000000..678b6ce3 --- /dev/null +++ b/docker/scripts/backup-entrypoint.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# Periodic pg_dump of the roboco DB (interim backup — no PITR/WAL archiving). +# Runs once on start, then every BACKUP_INTERVAL_SECONDS (default 24h). A +# failed dump logs and is retried next cycle; the loop itself never exits. +# ponytail: a plain sleep loop, not a cron daemon — good enough for a fixed +# 24h cadence, swap for a scheduler only if finer granularity is ever needed. +set -u + +BACKUP_DIR="${BACKUP_DIR:-/backups}" +KEEP="${BACKUP_KEEP:-14}" +INTERVAL_SECONDS="${BACKUP_INTERVAL_SECONDS:-86400}" + +# pg_dump reads these natively — no need to pass -h/-p/-U/-d by hand. +export PGHOST="${POSTGRES_HOST:-roboco-postgres}" +export PGPORT="${POSTGRES_PORT:-5432}" +export PGUSER="${POSTGRES_USER:-roboco}" +export PGPASSWORD="${POSTGRES_PASSWORD:-roboco}" +export PGDATABASE="${POSTGRES_DB:-roboco}" + +mkdir -p "$BACKUP_DIR" + +run_backup() { + local ts dest + ts="$(date -u +%Y%m%dT%H%M%SZ)" + dest="${BACKUP_DIR}/roboco-${ts}.dump" + echo "[backup] $(date -u -Iseconds) starting pg_dump -> ${dest}" + if pg_dump -Fc -f "${dest}.tmp"; then + mv "${dest}.tmp" "${dest}" + echo "[backup] $(date -u -Iseconds) OK: ${dest}" + else + rm -f "${dest}.tmp" + echo "[backup] $(date -u -Iseconds) FAILED — will retry next cycle" >&2 + fi + + # Prune to the newest $KEEP dumps. Filenames are our own fixed timestamp + # format (no spaces/globs to worry about), so plain `ls -t` is enough. + # shellcheck disable=SC2012 + ls -1t "${BACKUP_DIR}"/roboco-*.dump 2>/dev/null | tail -n "+$((KEEP + 1))" | while IFS= read -r old; do + rm -f "$old" + done +} + +while true; do + run_backup + sleep "$INTERVAL_SECONDS" +done diff --git a/docs/backend/ops/database-backups.md b/docs/backend/ops/database-backups.md new file mode 100644 index 00000000..c0990778 --- /dev/null +++ b/docs/backend/ops/database-backups.md @@ -0,0 +1,23 @@ +# Database Backups + +An interim `backup` sidecar service ships in both `docker-compose.yml` and `docker-compose.registry.yml`: a plain `pgvector/pgvector:pg16` container (the same image the `postgres` service pins) running `docker/scripts/backup-entrypoint.sh` on the `data` network alongside `postgres`, with no feature flag — it is always on, since a default-off backup is pointless. + +## What it does + +On container start, and then every `BACKUP_INTERVAL_SECONDS` (default `86400`, i.e. 24h), the script runs `pg_dump -Fc` against the `roboco` database over the network (via `PGHOST=roboco-postgres`, the same container-name resolution every other data-network consumer uses) and writes a timestamped custom-format dump to `${ROBOCO_DATA_DIR:-./data}/backups/roboco-.dump` on the host. The dump is written to a `.tmp` suffix first and only renamed into place on success, so a crash mid-dump never leaves a half-written file that looks complete. + +## Retention + +After each attempt the script prunes `${ROBOCO_DATA_DIR:-./data}/backups` down to the newest `BACKUP_KEEP` dumps (default `14`, i.e. roughly two weeks at the default 24h cadence) by mtime, deleting the rest. There is no offsite copy and no WAL/PITR archiving — this is a point-in-time `pg_dump` snapshot only, taken once a day. + +## Failure behavior + +A failed `pg_dump` (network hiccup, postgres briefly unhealthy, disk full) logs to `docker logs roboco-backup` and is retried on the next cycle; the loop itself never exits, so the container never crash-loops on a transient failure. `restart: unless-stopped` covers the rest. + +## Restoring a dump + +Stop anything writing to the database, then restore into a running (empty or throwaway) `roboco` database with `pg_restore`, for example: `docker exec -i roboco-postgres pg_restore -U roboco -d roboco --clean --if-exists < ./data/backups/roboco-20260711T030000Z.dump` (drop the `.tmp` files if any are present — they are in-progress dumps, not backups). Use `pg_restore -l ` first if you want to inspect or selectively restore a subset of objects rather than the whole database. For a fresh empty database instead of `--clean`, create it first (`createdb -U roboco roboco_restore`) and restore into that. + +## Known ceiling + +This is an interim measure to close the "zero backups" gap, not a full disaster-recovery story: a single daily snapshot on the same host as the database it's backing up is vulnerable to whole-host loss (disk failure, NAS failure). Copying `${ROBOCO_DATA_DIR:-./data}/backups` offsite periodically, or moving to WAL-based continuous archiving, is the natural next step if that risk matters more than the current simplicity. diff --git a/docs/components/a2a-filter-control.md b/docs/components/a2a-filter-control.md index e8fa1196..7ca1d9d6 100644 --- a/docs/components/a2a-filter-control.md +++ b/docs/components/a2a-filter-control.md @@ -1,8 +1,6 @@ # A2A Conversations Filter Control -**Location:** `panel/src/components/a2a/` -**Design spec:** `docs/ux_ui/design/conversations-filter-control.md` -**Related:** A2A page (`panel/src/app/(dashboard)/a2a/page.tsx`) +**Location:** `panel/src/components/a2a/` **Design spec:** `docs/ux_ui/design/conversations-filter-control.md` **Related:** A2A page (`panel/src/app/(dashboard)/a2a/page.tsx`) ## Overview @@ -207,17 +205,13 @@ function A2APageContent() { ### Filter Functions -**`filterConversations(conversations, filters): AdminConversationSummary[]`** -Applies all four dimensions (Agent, Task, Status, Date) to narrow the conversation list. +**`filterConversations(conversations, filters): AdminConversationSummary[]`** Applies all four dimensions (Agent, Task, Status, Date) to narrow the conversation list. -**`filterPairs(pairs, filters): AdminPairSummary[]`** -Applies Agent dimension only to narrow switchboard pair cards. +**`filterPairs(pairs, filters): AdminPairSummary[]`** Applies Agent dimension only to narrow switchboard pair cards. -**`distinctA2AAgents(conversations, pairs): string[]`** -Derives the checkbox option set by scanning all loaded pairs and conversations, deduping agent slugs, and sorting alphabetically. Call this in a `useMemo` in the parent whenever pairs/conversations change. +**`distinctA2AAgents(conversations, pairs): string[]`** Derives the checkbox option set by scanning all loaded pairs and conversations, deduping agent slugs, and sorting alphabetically. Call this in a `useMemo` in the parent whenever pairs/conversations change. -**`activeA2AFilterCount(filters): number`** -Returns the count of active filter values (one per chip). Drives the trigger's count badge. An empty fragment/date counts as 0; a set date counts as 1 per date. +**`activeA2AFilterCount(filters): number`** Returns the count of active filter values (one per chip). Drives the trigger's count badge. An empty fragment/date counts as 0; a set date counts as 1 per date. ## Per-View Rules @@ -303,14 +297,10 @@ The component follows the design spec's accessibility contract: ## Common Questions -**Q: Why doesn't the fragment search the topic field anymore?** -A: The design spec replaced free-text search with four discrete dimensions. Task ID fragment and Agent are the most common filters; if you need to search topics, that would be a fifth dimension—raise it in design review if needed. +**Q: Why doesn't the fragment search the topic field anymore?** A: The design spec replaced free-text search with four discrete dimensions. Task ID fragment and Agent are the most common filters; if you need to search topics, that would be a fifth dimension—raise it in design review if needed. -**Q: Can I make filters persist across page reloads?** -A: Not in this version. To add localStorage persistence, wrap `setFilters` in the parent with `useEffect` to sync to localStorage and restore on mount. This would be a follow-up task. +**Q: Can I make filters persist across page reloads?** A: Not in this version. To add localStorage persistence, wrap `setFilters` in the parent with `useEffect` to sync to localStorage and restore on mount. This would be a follow-up task. -**Q: What happens to filtered state when new conversations arrive via WebSocket?** -A: Filters remain active. The `filterConversations` function is re-run against the refreshed list on every data update, so incoming messages are immediately re-evaluated against the current filters. +**Q: What happens to filtered state when new conversations arrive via WebSocket?** A: Filters remain active. The `filterConversations` function is re-run against the refreshed list on every data update, so incoming messages are immediately re-evaluated against the current filters. -**Q: Can filters be set via URL query params?** -A: Not currently. The state is ephemeral. To support deep-linking (e.g., `?agents=be-dev-1&status=active`), add a URL param sync layer in the parent using `useSearchParams` / `useRouter` from Next.js. This would be a follow-up task. +**Q: Can filters be set via URL query params?** A: Not currently. The state is ephemeral. To support deep-linking (e.g., `?agents=be-dev-1&status=active`), add a URL param sync layer in the parent using `useSearchParams` / `useRouter` from Next.js. This would be a follow-up task. diff --git a/docs/frontend/a2a-conversation-first-layout.md b/docs/frontend/a2a-conversation-first-layout.md index 4cc14f71..e17171c2 100644 --- a/docs/frontend/a2a-conversation-first-layout.md +++ b/docs/frontend/a2a-conversation-first-layout.md @@ -163,8 +163,7 @@ Roster (col-span-4) | Stream (col-span-8) The connection badge renders in the pane header, and a dismissable banner appears above the message list when reconnecting or disconnected. -**File:** `panel/src/components/a2a/a2a-connection-badge.tsx` -**Utils:** `panel/src/components/a2a/a2a-utils.ts` +**File:** `panel/src/components/a2a/a2a-connection-badge.tsx` **Utils:** `panel/src/components/a2a/a2a-utils.ts` ### `ConnectionState` Type (from `@/lib/websocket/connection`) diff --git a/docs/ux_ui/design/02-conversation-first-layout-agent-identity-live-stream.md b/docs/ux_ui/design/02-conversation-first-layout-agent-identity-live-stream.md index 6d343c21..5c59de51 100644 --- a/docs/ux_ui/design/02-conversation-first-layout-agent-identity-live-stream.md +++ b/docs/ux_ui/design/02-conversation-first-layout-agent-identity-live-stream.md @@ -1,18 +1,10 @@ # Conversation-first layout, agent identity, and live-stream affordances -Interaction spec for the pattern that makes a live conversation the primary -surface of a view, rather than a secondary panel bolted onto a data table: a -three-region layout, a team-color agent identity scheme that scales to the -full 22-agent roster, connection-state visual treatment, a new-message -arrival cue, and the loading/empty/error states a conversation panel needs. -Written so a frontend developer can implement directly from this document -without further design clarification. +Interaction spec for the pattern that makes a live conversation the primary surface of a view, rather than a secondary panel bolted onto a data table: a three-region layout, a team-color agent identity scheme that scales to the full 22-agent roster, connection-state visual treatment, a new-message arrival cue, and the loading/empty/error states a conversation panel needs. Written so a frontend developer can implement directly from this document without further design clarification. ## Scope and where this lives -This is a **pattern spec**, not a new page proposal. It extends the one -conversation surface RoboCo already ships, -`panel/src/app/(dashboard)/a2a/page.tsx`, plus its sub-components: +This is a **pattern spec**, not a new page proposal. It extends the one conversation surface RoboCo already ships, `panel/src/app/(dashboard)/a2a/page.tsx`, plus its sub-components: | Piece | Existing file it extends | |---|---| @@ -22,20 +14,9 @@ conversation surface RoboCo already ships, | Agent identity | `panel/src/lib/agent-utils.ts` (`getAgentInitials`, `getAgentDisplayName`) | | Connection state | `panel/src/hooks/use-websocket.ts` (`ConnectionState` = `"connecting" \| "connected" \| "reconnecting" \| "disconnected"`), `panel/src/components/layout/connection-status.tsx` | -Nothing here replaces the `/a2a` page's existing behavior (message fetch, -reply composer, switchboard/list toggle) — every section below is additive: -a third pane, a color layer on an existing avatar, a refined connection -badge, an entrance transition for new rows, and the states around the -stream when it has nothing (yet) to show. The same three-region composition -and identity/connection/arrival treatment apply to any future conversation -surface RoboCo adds (e.g. a unified agent-activity inbox) without -re-deriving the pattern. +Nothing here replaces the `/a2a` page's existing behavior (message fetch, reply composer, switchboard/list toggle) — every section below is additive: a third pane, a color layer on an existing avatar, a refined connection badge, an entrance transition for new rows, and the states around the stream when it has nothing (yet) to show. The same three-region composition and identity/connection/arrival treatment apply to any future conversation surface RoboCo adds (e.g. a unified agent-activity inbox) without re-deriving the pattern. -**Design bar dial read:** dense product UI (a data-heavy live-ops surface -inside existing panel chrome), not a landing page — variance 2, motion 2-3, -density 7, the UX/UI cell's dashboard default. No new radius or shadow -tokens; color additions are a bounded, named palette (below), not ad hoc -hex values. +**Design bar dial read:** dense product UI (a data-heavy live-ops surface inside existing panel chrome), not a landing page — variance 2, motion 2-3, density 7, the UX/UI cell's dashboard default. No new radius or shadow tokens; color additions are a bounded, named palette (below), not ad hoc hex values. --- @@ -43,8 +24,7 @@ hex values. ### The three regions -A conversation-first surface is composed of three regions, always in this -order left-to-right, with the stream always the widest: +A conversation-first surface is composed of three regions, always in this order left-to-right, with the stream always the widest: ``` ┌─ Roster (list rail) ─┬───── Stream (primary) ─────┬─ Context (collapsible) ─┐ @@ -55,20 +35,13 @@ order left-to-right, with the stream always the widest: └────────────────────────┴───────────────────────────────┴─────────────────────┘ ``` -- **Roster** is navigation: "which conversation am I looking at" — today's - `A2ASwitchboard`/`A2AConversationList`. -- **Stream** is content: "what was said" — today's `A2ATranscript` plus the - reply composer beneath it. This region gets the majority of horizontal - space at every breakpoint that shows more than one region, because it is - the primary surface, not a byproduct of the roster selection. -- **Context** is metadata: participant identity detail, the linked task - (title, status, a link into `/tasks/{id}`), and any quick actions — a new - region, collapsible, not present in the current implementation. +- **Roster** is navigation: "which conversation am I looking at" — today's `A2ASwitchboard`/`A2AConversationList`. +- **Stream** is content: "what was said" — today's `A2ATranscript` plus the reply composer beneath it. This region gets the majority of horizontal space at every breakpoint that shows more than one region, because it is the primary surface, not a byproduct of the roster selection. +- **Context** is metadata: participant identity detail, the linked task (title, status, a link into `/tasks/{id}`), and any quick actions — a new region, collapsible, not present in the current implementation. ### Grid and breakpoints -Extends the existing `grid grid-cols-12 gap-4 lg:gap-6` container -(`a2a/page.tsx:260`) with one more breakpoint tier rather than replacing it: +Extends the existing `grid grid-cols-12 gap-4 lg:gap-6` container (`a2a/page.tsx:260`) with one more breakpoint tier rather than replacing it: | Breakpoint | Regions visible | Column split | |---|---|---| @@ -76,31 +49,17 @@ Extends the existing `grid grid-cols-12 gap-4 lg:gap-6` container | `lg` – `< xl` | Roster + Stream (today's behavior, unchanged) | Roster `col-span-4`, Stream `col-span-8` | | `xl`+ | Roster + Stream + Context | Roster `col-span-3`, Stream `col-span-6`, Context `col-span-3` | -The context pane is the new addition and is the one that collapses first — -it never appears below `xl`, and even at `xl`+ it is dismissible via a -header toggle (a `PanelRightClose`/`PanelRightOpen` icon button, `size="sm" -variant="ghost"`, matching the existing switchboard/list toggle buttons at -`a2a/page.tsx:275-296`) so a user who wants the stream at full width above -`xl` can still get it. Collapsed state persists in `localStorage` -(`roboco:conversation-context-open`, boolean), read once at mount — the same -persistence idiom already used for panel-width/theme preferences (avoids a -new state-management dependency). +The context pane is the new addition and is the one that collapses first — it never appears below `xl`, and even at `xl`+ it is dismissible via a header toggle (a `PanelRightClose`/`PanelRightOpen` icon button, `size="sm" variant="ghost"`, matching the existing switchboard/list toggle buttons at `a2a/page.tsx:275-296`) so a user who wants the stream at full width above `xl` can still get it. Collapsed state persists in `localStorage` (`roboco:conversation-context-open`, boolean), read once at mount — the same persistence idiom already used for panel-width/theme preferences (avoids a new state-management dependency). ### Context pane content When open, the context pane shows, top to bottom: -1. Both participants' identity cards (avatar + name + team badge — see - §2), each linking to `/agents/{slug}`. -2. The linked task, if any: title (truncated to one line), status `Badge` - (reusing the same `variant` mapping already used at `a2a/page.tsx:337-344`), - and a "View task" link. -3. A muted one-line hint when there is no linked task ("This conversation - has no linked task"), matching the tone of the existing no-task composer - message (`a2a/page.tsx:373-377`). +1. Both participants' identity cards (avatar + name + team badge — see §2), each linking to `/agents/{slug}`. +2. The linked task, if any: title (truncated to one line), status `Badge` (reusing the same `variant` mapping already used at `a2a/page.tsx:337-344`), and a "View task" link. +3. A muted one-line hint when there is no linked task ("This conversation has no linked task"), matching the tone of the existing no-task composer message (`a2a/page.tsx:373-377`). -The context pane does not duplicate the reply composer or transcript — it -is read-only summary, never a second place to act on the conversation. +The context pane does not duplicate the reply composer or transcript — it is read-only summary, never a second place to act on the conversation. --- @@ -108,22 +67,11 @@ is read-only summary, never a second place to act on the conversation. ### Why team color, not per-agent color -With 22 agents in the roster (`AGENT_UUIDS` in `agent-utils.ts`), a unique -hue per agent is not legible — nobody can hold 22 arbitrary colors in -working memory, and two similar hues (e.g. two blues for `be-dev-1` and -`fe-dev-1`) would read as "the same agent" at a glance. Colour is scoped to -the axis that actually matters for fast scanning — **which cell this agent -belongs to** — and individual identity within a team is carried by the -existing initials/code, not a second hue. This scales cleanly: adding a -23rd agent to an existing team changes zero colors; adding a whole new team -is the only case that needs a new bucket, and the palette below already has -headroom. +With 22 agents in the roster (`AGENT_UUIDS` in `agent-utils.ts`), a unique hue per agent is not legible — nobody can hold 22 arbitrary colors in working memory, and two similar hues (e.g. two blues for `be-dev-1` and `fe-dev-1`) would read as "the same agent" at a glance. Colour is scoped to the axis that actually matters for fast scanning — **which cell this agent belongs to** — and individual identity within a team is carried by the existing initials/code, not a second hue. This scales cleanly: adding a 23rd agent to an existing team changes zero colors; adding a whole new team is the only case that needs a new bucket, and the palette below already has headroom. ### The six buckets -A new pure function, `getAgentTeamColor(agentId: string | null | undefined): -AgentTeamColor`, colocated in `agent-utils.ts` next to `getAgentInitials` -(same module — it needs the same slug-resolution logic already there): +A new pure function, `getAgentTeamColor(agentId: string | null | undefined): AgentTeamColor`, colocated in `agent-utils.ts` next to `getAgentInitials` (same module — it needs the same slug-resolution logic already there): ```ts export type AgentTeamColor = @@ -135,11 +83,7 @@ export type AgentTeamColor = | "system"; ``` -Derived from the slug prefix (`be-*` → `backend`, `fe-*` → `frontend`, -`ux-*` → `ux_ui`, `main-pm`/`product-owner`/`head-marketing`/`auditor` → -`board`, `ceo`/`CEO` → `ceo`, `intake-*`/`secretary-*`/`pr-reviewer-*` → -`system`), with the same UUID-to-slug resolution `getAgentInitials` already -does via `resolveToSlug`. +Derived from the slug prefix (`be-*` → `backend`, `fe-*` → `frontend`, `ux-*` → `ux_ui`, `main-pm`/`product-owner`/`head-marketing`/`auditor` → `board`, `ceo`/`CEO` → `ceo`, `intake-*`/`secretary-*`/`pr-reviewer-*` → `system`), with the same UUID-to-slug resolution `getAgentInitials` already does via `resolveToSlug`. | Bucket | Agents | Token classes (light / dark handled by existing `dark:` pairs already in the codebase's Tailwind v4 setup) | |---|---|---| @@ -150,19 +94,11 @@ does via `resolveToSlug`. | `ceo` | ceo | `bg-primary/15 border-primary/40 text-primary` (the app's own accent — the one human gets the app's own color, not a team bucket) | | `system` | intake-1, secretary-1, pr-reviewer-1 | `bg-slate-500/15 border-slate-500/40 text-slate-700 dark:text-slate-400` | -Every value here is an existing Tailwind color family already used -elsewhere in the codebase for the same semantic weight (`amber` for -attention in `release-proposal-card.tsx:181`, `blue`/`violet`/`fuchsia` are -Tailwind defaults, no new tokens introduced) at the same `/15` background + -`/40` border opacity already established by the pulse-card treatment in -`a2a-pair-card.tsx:87`. +Every value here is an existing Tailwind color family already used elsewhere in the codebase for the same semantic weight (`amber` for attention in `release-proposal-card.tsx:181`, `blue`/`violet`/`fuchsia` are Tailwind defaults, no new tokens introduced) at the same `/15` background + `/40` border opacity already established by the pulse-card treatment in `a2a-pair-card.tsx:87`. ### Avatar composition -Extends the existing avatar circle (`PairAvatar` in `a2a-pair-card.tsx:20-31`, -and the inline avatar in `a2a-transcript.tsx:70-74`) with the team color as -`border` + `bg`, keeping the initials as the foreground content — the color -becomes a ring around identity, not a replacement for it: +Extends the existing avatar circle (`PairAvatar` in `a2a-pair-card.tsx:20-31`, and the inline avatar in `a2a-transcript.tsx:70-74`) with the team color as `border` + `bg`, keeping the initials as the foreground content — the color becomes a ring around identity, not a replacement for it: ```tsx
``` -`TEAM_COLOR_CLASSES` is a `Record` map of the class -strings from the table above, exported alongside `getAgentTeamColor` so -every consumer (transcript rows, pair cards, roster rows, context pane -identity cards) applies the identical mapping — one source of truth, no -per-component re-derivation. +`TEAM_COLOR_CLASSES` is a `Record` map of the class strings from the table above, exported alongside `getAgentTeamColor` so every consumer (transcript rows, pair cards, roster rows, context pane identity cards) applies the identical mapping — one source of truth, no per-component re-derivation. ### Accessibility -Color is never the sole differentiator: the `title` attribute always -carries the full display name (already the case in `PairAvatar`), the -initials/code is always visible text inside the circle, and every place an -avatar appears the agent's display name renders as adjacent text (already -true in the transcript and pair card). A screen reader user gets the name -from the text content regardless of the color layer. All six token pairs -above meet WCAG AA (4.5:1) for the `text-*-700`/`text-*-400` foreground -against a `bg-*-500/15` fill over the app's `background`/`card` surface — -verify against the actual rendered surface at implementation time per the -design bar's contrast-audit rule, since a `/15` alpha fill's effective -contrast depends on what's behind it. +Color is never the sole differentiator: the `title` attribute always carries the full display name (already the case in `PairAvatar`), the initials/code is always visible text inside the circle, and every place an avatar appears the agent's display name renders as adjacent text (already true in the transcript and pair card). A screen reader user gets the name from the text content regardless of the color layer. All six token pairs above meet WCAG AA (4.5:1) for the `text-*-700`/`text-*-400` foreground against a `bg-*-500/15` fill over the app's `background`/`card` surface — verify against the actual rendered surface at implementation time per the design bar's contrast-audit rule, since a `/15` alpha fill's effective contrast depends on what's behind it. --- @@ -204,10 +126,7 @@ contrast depends on what's behind it. ### States -`ConnectionState` already has four values (`use-websocket.ts:17-21` via -`lib/websocket/connection.ts`); the spec covers all four, since -`"connecting"` (initial handshake) and `"reconnecting"` (recovering after a -drop) share one visual family with a different label: +`ConnectionState` already has four values (`use-websocket.ts:17-21` via `lib/websocket/connection.ts`); the spec covers all four, since `"connecting"` (initial handshake) and `"reconnecting"` (recovering after a drop) share one visual family with a different label: | State | Dot | Label | Icon (header, inline) | Placement | |---|---|---|---|---| @@ -216,27 +135,13 @@ drop) share one visual family with a different label: | `reconnecting` | `bg-amber-500`, `animate-pulse` | "Reconnecting…" | `Loader2` with `animate-spin`, `h-3 w-3` | Inline in the pane header, **plus** a thin dismissable strip directly above the stream pane's message list: `bg-amber-500/10 border-b border-amber-500/30 text-amber-700 dark:text-amber-400 text-xs px-3 py-1.5` reading "Reconnecting — messages may be out of date" | | `disconnected` | `bg-muted-foreground/40`, static | "Offline" | `WifiOff`, `h-3 w-3`, `text-muted-foreground` | Inline in the pane header, **plus** the same strip pattern as `reconnecting` but `bg-destructive/10 border-destructive/30 text-destructive`, reading "Disconnected — reconnecting automatically" | -The `connected`/`connecting`/`reconnecting` distinction matters because a -user watching a live conversation needs to know *why* nothing new is -arriving: `connected`-but-quiet means the conversation is genuinely idle; -`reconnecting`/`disconnected` means the stream itself is the problem, not -the conversation. Collapsing all three into one generic "not live" state -(as today's binary `isConnected ? "Live" : "Offline"` does) hides that -distinction. +The `connected`/`connecting`/`reconnecting` distinction matters because a user watching a live conversation needs to know *why* nothing new is arriving: `connected`-but-quiet means the conversation is genuinely idle; `reconnecting`/`disconnected` means the stream itself is the problem, not the conversation. Collapsing all three into one generic "not live" state (as today's binary `isConnected ? "Live" : "Offline"` does) hides that distinction. -The banner strip is scoped to the stream pane only, not a full-page -takeover — this is a live-connection hint, not an application-down state -(that's `OfflineState`, reserved for §5's error case where data can't load -at all). +The banner strip is scoped to the stream pane only, not a full-page takeover — this is a live-connection hint, not an application-down state (that's `OfflineState`, reserved for §5's error case where data can't load at all). ### Motion note -The existing `animate-pulse` dot (`a2a/page.tsx:228`) is a Tailwind -keyframe that only animates `opacity`, so it already satisfies the "animate -transform/opacity only" rule — but it has no `prefers-reduced-motion` guard -today. Add one: wrap the pulsing states in `motion-reduce:animate-none`, so -a reduced-motion user gets a static dot at full opacity instead of the -pulse — the color and label alone still convey the state. +The existing `animate-pulse` dot (`a2a/page.tsx:228`) is a Tailwind keyframe that only animates `opacity`, so it already satisfies the "animate transform/opacity only" rule — but it has no `prefers-reduced-motion` guard today. Add one: wrap the pulsing states in `motion-reduce:animate-none`, so a reduced-motion user gets a static dot at full opacity instead of the pulse — the color and label alone still convey the state. --- @@ -244,11 +149,7 @@ pulse — the color and label alone still convey the state. ### The cue -When a new message is appended to the stream (a `a2a.message` frame that -resolves to a new row after the existing invalidate-on-frame refetch, -`a2a/page.tsx:131-140`), the new row enters with a **transform + opacity -only** transition — no layout-affecting property, no scroll-listener-driven -animation, per the design bar's motion rule: +When a new message is appended to the stream (a `a2a.message` frame that resolves to a new row after the existing invalidate-on-frame refetch, `a2a/page.tsx:131-140`), the new row enters with a **transform + opacity only** transition — no layout-affecting property, no scroll-listener-driven animation, per the design bar's motion rule: ```tsx className={cn( @@ -257,25 +158,13 @@ className={cn( )} ``` -`isNew` is derived the same render-phase way `A2APairCard`'s `isPulsing` -already is (`a2a-pair-card.tsx:49-60`): compare the incoming message id -against a "last seen" set in render, flip to `false` on the next animation -frame via `requestAnimationFrame` inside a `useEffect` — no animation -library, matching the codebase's existing idiom for this exact kind of -one-shot entrance state. +`isNew` is derived the same render-phase way `A2APairCard`'s `isPulsing` already is (`a2a-pair-card.tsx:49-60`): compare the incoming message id against a "last seen" set in render, flip to `false` on the next animation frame via `requestAnimationFrame` inside a `useEffect` — no animation library, matching the codebase's existing idiom for this exact kind of one-shot entrance state. -The starting state (`opacity-0 translate-y-1`, i.e. 4px down) is applied -only for rows that mount already-new (a message arriving while the stream -is open); rows present at initial transcript load render straight to -`opacity-100 translate-y-0` with no transition, so opening a conversation -never shows every existing message animating in at once. +The starting state (`opacity-0 translate-y-1`, i.e. 4px down) is applied only for rows that mount already-new (a message arriving while the stream is open); rows present at initial transcript load render straight to `opacity-100 translate-y-0` with no transition, so opening a conversation never shows every existing message animating in at once. ### Off-screen arrival (scrolled up) -When the user has scrolled up in the stream (not at the bottom) and a new -message arrives, do not auto-scroll and do not play the row-entrance -transition off-screen. Instead show a small pill anchored to the bottom of -the stream pane: +When the user has scrolled up in the stream (not at the bottom) and a new message arrives, do not auto-scroll and do not play the row-entrance transition off-screen. Instead show a small pill anchored to the bottom of the stream pane: ```tsx