mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
[a360b6e3] Redesign A2A page with conversation-first layout and agent identity (#401)
* [54b94e44] A2A page: filter controls + agent identity consistency (#387) (#392) * [54b94e44] feat(a2a): add filter bar and unify agent avatars + pulse across views Adds a status (active/all) + free-text search filter bar above the A2A switchboard/list content, backed by a shared a2a-filter-utils module so both A2ASwitchboard's pairs and A2AConversationList's conversations narrow identically. Extracts A2APairCard's pulse-flash state into a reusable usePulseFlash hook and exports its PairAvatar so the classic conversation list now renders the same two-participant avatar and emerald pulse-flash affordance the switchboard already had. * [54b94e44] docs(a2a): add comprehensive filtering and avatar documentation Documented the new A2A filter bar, filter utilities, pulse-flash hook, and conversation list API changes. Includes examples, testing guidance, and migration notes for the pulses prop requirement. --------- Co-authored-by: Frontend Developer 1 <fe-dev-1@roboco.tech> Co-authored-by: Frontend Documenter <fe-doc@roboco.tech> * [54417f0c] UX/UI: design A2A conversation-first layout and agent identity (#399) * [f612a5ab] Add conversation-first layout, agent identity, and live-stream affordance spec (#384) Co-authored-by: UX/UI Developer 1 <ux-dev-1@roboco.tech> * [7ed2ef71] docs(ux_ui): add filter-control design spec for A2A conversations (#383) Co-authored-by: UX/UI Developer 2 <ux-dev-2@roboco.tech> --------- Co-authored-by: UX/UI Developer 1 <ux-dev-1@roboco.tech> Co-authored-by: UX/UI Developer 2 <ux-dev-2@roboco.tech> * [f563bbc9] Implement conversation-first A2A layout, identity colors, connection states, transcript motion, and empty/error states (#423) (#427) * [f563bbc9] feat(a2a): conversation-first layout, team-color identity, connection states, transcript motion, empty/error states Implements docs/ux_ui/design/02-conversation-first-layout-agent-identity-live-stream.md: - xl:+ collapsible Context pane (identity cards, linked-task summary, no-task hint), persisted via the existing zustand ui-store - getAgentTeamColor + TEAM_COLOR_CLASSES in agent-utils.ts, applied to PairAvatar, the transcript row avatar, and the context pane - A2AConnectionBadge/A2AConnectionBanner rendering all four ConnectionState values distinctly with a motion-reduce-guarded pulsing dot and a dismissable reconnecting/disconnected strip - A2ATranscript: transform/opacity-only new-row entrance transition, scrolled-up "New messages" pill, split hasSelection/empty/error states with a scoped Retry - Unit tests for every new pure helper and component * [f563bbc9] docs(a2a): conversation-first layout, team-color identity, connection states, transcript motion, empty/error states Document the new conversation-first A2A layout features: - Agent team-color system (getAgentTeamColor, TEAM_COLOR_CLASSES) for six cell buckets - A2AContextPane component with identity cards, linked task summary, no-task hint - Connection state rendering (A2AConnectionBadge, A2AConnectionBanner) for all four ConnectionState values - Transcript entrance motion with transform/opacity-only transitions and prefers-reduced-motion guards - Split empty/error states (no selection, no messages, fetch error with scoped retry) - Page-level integration with xl:+ responsive grid layout Includes component API, usage examples, testing guidance, accessibility notes, and design rationale. --------- Co-authored-by: Frontend Developer 2 <fe-dev-2@roboco.tech> Co-authored-by: Frontend Documenter <fe-doc@roboco.tech> * [478f027c] Implement A2A conversations filter control per conversations-filter-control.md (#445) (#448) * [478f027c] feat(a2a): add multi-dimension Popover filter panel for A2A conversations Replace the free-text search + active/all toggle with the Popover-triggered filter control from conversations-filter-control.md: Agent multi-select checkboxes, a Task id-fragment input with a "No linked task" toggle, Status toggle buttons, and a date range, plus an active-filter chip row and Clear all. Filtering applies to both the switchboard (Agent only) and conversation list (all four dimensions) per the design doc's per-view rules. * [478f027c] docs(a2a): add comprehensive filter-control guide covering component API, filter dimensions, and per-view rules Documents A2AFilterBar component and filter utilities with: - Component API and props - All 4 filter dimensions (Agent, Task, Status, Date range) - Per-view rules (Switchboard vs List) - Usage examples and parent setup - Filter logic and match predicates - Testing guide and accessibility notes - Design notes on client-side filtering limitation - Links to related components and the design spec Helps developers understand, use, and maintain the A2A conversations filter control without needing to read the design doc or component source. --------- Co-authored-by: Frontend Developer 1 <fe-dev-1@roboco.tech> Co-authored-by: Frontend Documenter <fe-doc@roboco.tech> --------- Co-authored-by: Frontend Developer 1 <fe-dev-1@roboco.tech> Co-authored-by: Frontend Documenter <fe-doc@roboco.tech> Co-authored-by: UX/UI Developer 1 <ux-dev-1@roboco.tech> Co-authored-by: UX/UI Developer 2 <ux-dev-2@roboco.tech> Co-authored-by: Frontend Developer 2 <fe-dev-2@roboco.tech> Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
co-authored by
Frontend Developer 1
Frontend Documenter
UX/UI Developer 1
UX/UI Developer 2
Frontend Developer 2
Renn F
parent
58354a364e
commit
6e57066bd6
+8
-2
@@ -13,6 +13,7 @@ When you bump the `next` package, **always update `eslint-config-next` to match*
|
||||
### Upgrade Procedure
|
||||
|
||||
1. **Update package.json** with the new version(s):
|
||||
|
||||
```json
|
||||
{
|
||||
"dependencies": {
|
||||
@@ -25,13 +26,16 @@ When you bump the `next` package, **always update `eslint-config-next` to match*
|
||||
```
|
||||
|
||||
2. **Regenerate the lockfile without deleting node_modules:**
|
||||
|
||||
```bash
|
||||
cd panel
|
||||
pnpm install
|
||||
```
|
||||
This updates `pnpm-lock.yaml` while preserving `node_modules`, keeping the resolution deterministic.
|
||||
|
||||
This updates `pnpm-lock.yaml` while preserving `node_modules`, keeping the resolution deterministic.
|
||||
|
||||
3. **Run the quality gate:**
|
||||
|
||||
```bash
|
||||
cd panel
|
||||
pnpm lint
|
||||
@@ -39,7 +43,8 @@ This updates `pnpm-lock.yaml` while preserving `node_modules`, keeping the resol
|
||||
pnpm test
|
||||
pnpm build
|
||||
```
|
||||
All must pass with no errors before committing.
|
||||
|
||||
All must pass with no errors before committing.
|
||||
|
||||
4. **Commit the changes:**
|
||||
```bash
|
||||
@@ -50,6 +55,7 @@ All must pass with no errors before committing.
|
||||
### What Changed in 16.1.1 → 16.2.6
|
||||
|
||||
This bump included updates to:
|
||||
|
||||
- `@babel/parser`, `@babel/types`, `@babel/template`, `@babel/traverse` — minor version improvements
|
||||
- `@babel/generator`, `@babel/helper-module-imports`, `@babel/helper-validator-identifier` — updated to handle edge cases
|
||||
- `tinyglobby` — dependency used by ESLint, upgraded from 0.2.15 to 0.2.17
|
||||
|
||||
+85
-312
@@ -1,38 +1,19 @@
|
||||
{
|
||||
"claim_rules": {
|
||||
"auditor": [],
|
||||
"cell_pm": [
|
||||
"needs_revision",
|
||||
"pending"
|
||||
],
|
||||
"cell_pm": ["needs_revision", "pending"],
|
||||
"ceo": [],
|
||||
"developer": [
|
||||
"needs_revision",
|
||||
"pending"
|
||||
],
|
||||
"documenter": [
|
||||
"awaiting_documentation",
|
||||
"pending"
|
||||
],
|
||||
"developer": ["needs_revision", "pending"],
|
||||
"documenter": ["awaiting_documentation", "pending"],
|
||||
"head_marketing": [],
|
||||
"main_pm": [
|
||||
"needs_revision",
|
||||
"pending"
|
||||
],
|
||||
"pr_reviewer": [
|
||||
"awaiting_pr_review",
|
||||
"pending"
|
||||
],
|
||||
"main_pm": ["needs_revision", "pending"],
|
||||
"pr_reviewer": ["awaiting_pr_review", "pending"],
|
||||
"product_owner": [],
|
||||
"qa": [
|
||||
"awaiting_qa"
|
||||
]
|
||||
"qa": ["awaiting_qa"]
|
||||
},
|
||||
"intents": [
|
||||
{
|
||||
"allowed_roles": [
|
||||
"documenter"
|
||||
],
|
||||
"allowed_roles": ["documenter"],
|
||||
"composes": [],
|
||||
"description": "Claim awaiting_documentation. Returns evidence inline.",
|
||||
"name": "claim_doc_task",
|
||||
@@ -40,9 +21,7 @@
|
||||
"side_effects": []
|
||||
},
|
||||
{
|
||||
"allowed_roles": [
|
||||
"pr_reviewer"
|
||||
],
|
||||
"allowed_roles": ["pr_reviewer"],
|
||||
"composes": [],
|
||||
"description": "Claim an assembled-PR review task (awaiting_pr_review) WITHOUT transitioning it \u2014 mirrors QA's claim_review. The assembled diff and the parent task's acceptance criteria are returned inline.",
|
||||
"name": "claim_gate_review",
|
||||
@@ -50,22 +29,15 @@
|
||||
"side_effects": []
|
||||
},
|
||||
{
|
||||
"allowed_roles": [
|
||||
"pr_reviewer"
|
||||
],
|
||||
"composes": [
|
||||
"claim",
|
||||
"start"
|
||||
],
|
||||
"allowed_roles": ["pr_reviewer"],
|
||||
"composes": ["claim", "start"],
|
||||
"description": "Claim an inbound external-PR review task and start work. pending -> claimed -> in_progress.",
|
||||
"name": "claim_pr_review",
|
||||
"pre_side_effects": [],
|
||||
"side_effects": []
|
||||
},
|
||||
{
|
||||
"allowed_roles": [
|
||||
"qa"
|
||||
],
|
||||
"allowed_roles": ["qa"],
|
||||
"composes": [],
|
||||
"description": "Claim a task in awaiting_qa for review. Returns evidence inline.",
|
||||
"name": "claim_review",
|
||||
@@ -73,23 +45,15 @@
|
||||
"side_effects": []
|
||||
},
|
||||
{
|
||||
"allowed_roles": [
|
||||
"cell_pm",
|
||||
"main_pm"
|
||||
],
|
||||
"composes": [
|
||||
"complete"
|
||||
],
|
||||
"allowed_roles": ["cell_pm", "main_pm"],
|
||||
"composes": ["complete"],
|
||||
"description": "Cell PM merges the PR (leaf into the cell branch, or the gated cell\u2192root PR into the root branch) + transitions to completed; Main PM escalates the root to the CEO (who merges root\u2192master). The merge runs BEFORE the complete transition: TaskService.complete asserts the PR is already merged, so the choreographer verb body (cell_pm_complete / main_pm_complete) owns the merge-first ordering \u2014 no trailing pr_merge side_effect is declared here.",
|
||||
"name": "complete",
|
||||
"pre_side_effects": [],
|
||||
"side_effects": []
|
||||
},
|
||||
{
|
||||
"allowed_roles": [
|
||||
"cell_pm",
|
||||
"main_pm"
|
||||
],
|
||||
"allowed_roles": ["cell_pm", "main_pm"],
|
||||
"composes": [],
|
||||
"description": "Stamp parent acceptance criteria onto an existing child's parent_ac_refs after the fact \u2014 for a replacement child whose delegate omitted covers_parent_criteria. Or, targeting your OWN root/coordination task, declare criteria as root-owned (only your own machinery satisfies them \u2014 never push these into a cell). No status change; the verb body owns ownership + criterion validation.",
|
||||
"name": "declare_coverage",
|
||||
@@ -97,37 +61,23 @@
|
||||
"side_effects": []
|
||||
},
|
||||
{
|
||||
"allowed_roles": [
|
||||
"cell_pm",
|
||||
"main_pm"
|
||||
],
|
||||
"composes": [
|
||||
"create_subtask"
|
||||
],
|
||||
"allowed_roles": ["cell_pm", "main_pm"],
|
||||
"composes": ["create_subtask"],
|
||||
"description": "Create a subtask under the current task. Validates the delegation chain (main_pm->cell_pm; cell_pm->its team's devs) and the assignee-vs-task_type rule (Cell PMs get planning-typed tasks; devs get code/research, UX devs also design). documentation is NOT delegatable \u2014 the lifecycle auto-creates the doc phase after the code subtask passes QA.",
|
||||
"name": "delegate",
|
||||
"pre_side_effects": [],
|
||||
"side_effects": []
|
||||
},
|
||||
{
|
||||
"allowed_roles": [
|
||||
"head_marketing",
|
||||
"main_pm",
|
||||
"product_owner"
|
||||
],
|
||||
"composes": [
|
||||
"escalate_to_ceo"
|
||||
],
|
||||
"allowed_roles": ["head_marketing", "main_pm", "product_owner"],
|
||||
"composes": ["escalate_to_ceo"],
|
||||
"description": "Escalate to CEO with reason. Transitions to awaiting_ceo_approval.",
|
||||
"name": "escalate_to_ceo",
|
||||
"pre_side_effects": [],
|
||||
"side_effects": []
|
||||
},
|
||||
{
|
||||
"allowed_roles": [
|
||||
"cell_pm",
|
||||
"main_pm"
|
||||
],
|
||||
"allowed_roles": ["cell_pm", "main_pm"],
|
||||
"composes": [],
|
||||
"description": "Escalate to your role's escalation_target.",
|
||||
"name": "escalate_up",
|
||||
@@ -135,12 +85,8 @@
|
||||
"side_effects": []
|
||||
},
|
||||
{
|
||||
"allowed_roles": [
|
||||
"qa"
|
||||
],
|
||||
"composes": [
|
||||
"qa_fail"
|
||||
],
|
||||
"allowed_roles": ["qa"],
|
||||
"composes": ["qa_fail"],
|
||||
"description": "Fail QA with concrete issues. Transitions to needs_revision.",
|
||||
"name": "fail_review",
|
||||
"pre_side_effects": [],
|
||||
@@ -162,27 +108,16 @@
|
||||
"side_effects": []
|
||||
},
|
||||
{
|
||||
"allowed_roles": [
|
||||
"developer",
|
||||
"documenter",
|
||||
"qa"
|
||||
],
|
||||
"composes": [
|
||||
"block"
|
||||
],
|
||||
"allowed_roles": ["developer", "documenter", "qa"],
|
||||
"composes": ["block"],
|
||||
"description": "Escalate to PM. Logs a struggle journal entry.",
|
||||
"name": "i_am_blocked",
|
||||
"pre_side_effects": [],
|
||||
"side_effects": []
|
||||
},
|
||||
{
|
||||
"allowed_roles": [
|
||||
"developer"
|
||||
],
|
||||
"composes": [
|
||||
"submit_verification",
|
||||
"submit_qa"
|
||||
],
|
||||
"allowed_roles": ["developer"],
|
||||
"composes": ["submit_verification", "submit_qa"],
|
||||
"description": "Submit work for QA. Auto-runs in_progress->verifying then verifying->awaiting_qa. Strict - PR must be open (call open_pr first) and >=1 commit.",
|
||||
"name": "i_am_done",
|
||||
"pre_side_effects": [],
|
||||
@@ -209,111 +144,71 @@
|
||||
"side_effects": []
|
||||
},
|
||||
{
|
||||
"allowed_roles": [
|
||||
"documenter"
|
||||
],
|
||||
"composes": [
|
||||
"docs_complete"
|
||||
],
|
||||
"allowed_roles": ["documenter"],
|
||||
"composes": ["docs_complete"],
|
||||
"description": "Signal docs complete. Transitions to awaiting_pm_review.",
|
||||
"name": "i_documented",
|
||||
"pre_side_effects": [],
|
||||
"side_effects": []
|
||||
},
|
||||
{
|
||||
"allowed_roles": [
|
||||
"cell_pm",
|
||||
"main_pm"
|
||||
],
|
||||
"composes": [
|
||||
"claim",
|
||||
"set_plan",
|
||||
"start"
|
||||
],
|
||||
"allowed_roles": ["cell_pm", "main_pm"],
|
||||
"composes": ["claim", "set_plan", "start"],
|
||||
"description": "PM mirror of i_will_work_on for parent tasks. Claim, plan, transition to in_progress; from there delegate subtasks.",
|
||||
"name": "i_will_plan",
|
||||
"pre_side_effects": [],
|
||||
"side_effects": []
|
||||
},
|
||||
{
|
||||
"allowed_roles": [
|
||||
"developer"
|
||||
],
|
||||
"composes": [
|
||||
"claim",
|
||||
"set_plan",
|
||||
"start"
|
||||
],
|
||||
"allowed_roles": ["developer"],
|
||||
"composes": ["claim", "set_plan", "start"],
|
||||
"description": "Claim a task, set the plan, and transition to in_progress. Atomic - preconditions checked before any state mutation.",
|
||||
"name": "i_will_work_on",
|
||||
"pre_side_effects": [],
|
||||
"side_effects": []
|
||||
},
|
||||
{
|
||||
"allowed_roles": [
|
||||
"developer"
|
||||
],
|
||||
"allowed_roles": ["developer"],
|
||||
"composes": [],
|
||||
"description": "Push the branch and open a PR. Atomic - preconditions (assignee, >=1 commit, no prior PR) checked BEFORE any git operation. After success, call i_am_done.",
|
||||
"name": "open_pr",
|
||||
"pre_side_effects": [],
|
||||
"side_effects": [
|
||||
"push_branch",
|
||||
"create_pr"
|
||||
]
|
||||
"side_effects": ["push_branch", "create_pr"]
|
||||
},
|
||||
{
|
||||
"allowed_roles": [
|
||||
"qa"
|
||||
],
|
||||
"composes": [
|
||||
"qa_pass"
|
||||
],
|
||||
"allowed_roles": ["qa"],
|
||||
"composes": ["qa_pass"],
|
||||
"description": "Pass QA. Transitions awaiting_qa -> awaiting_documentation.",
|
||||
"name": "pass_review",
|
||||
"pre_side_effects": [],
|
||||
"side_effects": []
|
||||
},
|
||||
{
|
||||
"allowed_roles": [
|
||||
"pr_reviewer"
|
||||
],
|
||||
"composes": [
|
||||
"pr_review_done"
|
||||
],
|
||||
"allowed_roles": ["pr_reviewer"],
|
||||
"composes": ["pr_review_done"],
|
||||
"description": "Post one complete change-request to the external PR and finish the review task. in_progress -> completed.",
|
||||
"name": "post_pr_review",
|
||||
"pre_side_effects": [],
|
||||
"side_effects": []
|
||||
},
|
||||
{
|
||||
"allowed_roles": [
|
||||
"pr_reviewer"
|
||||
],
|
||||
"composes": [
|
||||
"pr_fail"
|
||||
],
|
||||
"allowed_roles": ["pr_reviewer"],
|
||||
"composes": ["pr_fail"],
|
||||
"description": "Fail the assembled-PR review with concrete issues. Transitions awaiting_pr_review -> needs_revision, routed back like a QA fail.",
|
||||
"name": "pr_fail",
|
||||
"pre_side_effects": [],
|
||||
"side_effects": []
|
||||
},
|
||||
{
|
||||
"allowed_roles": [
|
||||
"pr_reviewer"
|
||||
],
|
||||
"composes": [
|
||||
"pr_pass"
|
||||
],
|
||||
"allowed_roles": ["pr_reviewer"],
|
||||
"composes": ["pr_pass"],
|
||||
"description": "Pass the assembled-PR review. Transitions awaiting_pr_review -> awaiting_pm_review so the PM can merge.",
|
||||
"name": "pr_pass",
|
||||
"pre_side_effects": [],
|
||||
"side_effects": []
|
||||
},
|
||||
{
|
||||
"allowed_roles": [
|
||||
"cell_pm"
|
||||
],
|
||||
"allowed_roles": ["cell_pm"],
|
||||
"composes": [],
|
||||
"description": "Hand a claimed/in_progress task to another developer in your own cell. The branch is keyed to the task (not the agent), so it is preserved \u2014 the new developer continues the work-in-progress. No status change.",
|
||||
"name": "reassign",
|
||||
@@ -321,66 +216,39 @@
|
||||
"side_effects": []
|
||||
},
|
||||
{
|
||||
"allowed_roles": [
|
||||
"cell_pm",
|
||||
"main_pm"
|
||||
],
|
||||
"composes": [
|
||||
"request_changes"
|
||||
],
|
||||
"allowed_roles": ["cell_pm", "main_pm"],
|
||||
"composes": ["request_changes"],
|
||||
"description": "Reject the merge review with concrete issues. Transitions awaiting_pm_review -> needs_revision, routed back like a QA fail (original developer for a leaf, revision PM for an assembled task). Use this for an AC/scope violation caught at merge review \u2014 never i_am_blocked/escalate, which have no revision routing.",
|
||||
"name": "request_changes",
|
||||
"pre_side_effects": [],
|
||||
"side_effects": []
|
||||
},
|
||||
{
|
||||
"allowed_roles": [
|
||||
"cell_pm",
|
||||
"developer",
|
||||
"documenter",
|
||||
"main_pm",
|
||||
"qa"
|
||||
],
|
||||
"composes": [
|
||||
"resume"
|
||||
],
|
||||
"allowed_roles": ["cell_pm", "developer", "documenter", "main_pm", "qa"],
|
||||
"composes": ["resume"],
|
||||
"description": "Resume a paused task you own. paused -> in_progress.",
|
||||
"name": "resume",
|
||||
"pre_side_effects": [],
|
||||
"side_effects": []
|
||||
},
|
||||
{
|
||||
"allowed_roles": [
|
||||
"main_pm"
|
||||
],
|
||||
"composes": [
|
||||
"submit_for_review"
|
||||
],
|
||||
"allowed_roles": ["main_pm"],
|
||||
"composes": ["submit_for_review"],
|
||||
"description": "Main PM opens the root\u2192master PR and moves the root task to awaiting_pr_review for the main reviewer (the root analogue of the cell PM's submit_up). After pr_pass, call complete to escalate to the CEO. For branch-bearing roots (a Main-PM root-subtask assembles the cells' merged work); branchless coordination roots skip the gate and complete directly. The gate is branch-keyed, not task_type-keyed \u2014 a Main-PM root is planning-typed, never code.",
|
||||
"name": "submit_root",
|
||||
"pre_side_effects": [
|
||||
"create_root_pr"
|
||||
],
|
||||
"pre_side_effects": ["create_root_pr"],
|
||||
"side_effects": []
|
||||
},
|
||||
{
|
||||
"allowed_roles": [
|
||||
"cell_pm"
|
||||
],
|
||||
"composes": [
|
||||
"submit_for_review"
|
||||
],
|
||||
"allowed_roles": ["cell_pm"],
|
||||
"composes": ["submit_for_review"],
|
||||
"description": "Cell PM opens the cell\u2192root PR and moves the cell task into the PR-review gate (awaiting_pr_review). The cell reviewer reviews the assembled diff; after pr_pass the same Cell PM completes it.",
|
||||
"name": "submit_up",
|
||||
"pre_side_effects": [
|
||||
"create_pr"
|
||||
],
|
||||
"pre_side_effects": ["create_pr"],
|
||||
"side_effects": []
|
||||
},
|
||||
{
|
||||
"allowed_roles": [
|
||||
"developer"
|
||||
],
|
||||
"allowed_roles": ["developer"],
|
||||
"composes": [],
|
||||
"description": "Rebase your task's branch onto its current base THROUGH the gate (raw git is denied). Use when your branch has fallen behind its base \u2014 e.g. a sibling task's PR merged into the parent branch while you worked. Fetches origin, rebases head onto base, and force-pushes (with-lease). No DB state change. On conflicts the rebase is aborted and the conflicted files are returned \u2014 resolve by hand, commit, then sync_branch again. Pass stash=True to auto-stash uncommitted changes instead of refusing DIRTY_WORKSPACE; they are restored after the rebase.",
|
||||
"name": "sync_branch",
|
||||
@@ -402,9 +270,7 @@
|
||||
"side_effects": []
|
||||
},
|
||||
{
|
||||
"allowed_roles": [
|
||||
"main_pm"
|
||||
],
|
||||
"allowed_roles": ["main_pm"],
|
||||
"composes": [],
|
||||
"description": "List actionable tasks across all teams (Main PM only).",
|
||||
"name": "triage_all",
|
||||
@@ -412,13 +278,8 @@
|
||||
"side_effects": []
|
||||
},
|
||||
{
|
||||
"allowed_roles": [
|
||||
"cell_pm",
|
||||
"main_pm"
|
||||
],
|
||||
"composes": [
|
||||
"unblock"
|
||||
],
|
||||
"allowed_roles": ["cell_pm", "main_pm"],
|
||||
"composes": ["unblock"],
|
||||
"description": "PM unblocks a blocked task; restores pre-block state.",
|
||||
"name": "unblock",
|
||||
"pre_side_effects": [],
|
||||
@@ -443,175 +304,121 @@
|
||||
"transitions": [
|
||||
{
|
||||
"action": "cancel",
|
||||
"roles": [
|
||||
"ceo"
|
||||
],
|
||||
"roles": ["ceo"],
|
||||
"source": "awaiting_ceo_approval",
|
||||
"target": "cancelled"
|
||||
},
|
||||
{
|
||||
"action": "ceo_approve",
|
||||
"roles": [
|
||||
"ceo"
|
||||
],
|
||||
"roles": ["ceo"],
|
||||
"source": "awaiting_ceo_approval",
|
||||
"target": "completed"
|
||||
},
|
||||
{
|
||||
"action": "ceo_reject",
|
||||
"roles": [
|
||||
"ceo"
|
||||
],
|
||||
"roles": ["ceo"],
|
||||
"source": "awaiting_ceo_approval",
|
||||
"target": "needs_revision"
|
||||
},
|
||||
{
|
||||
"action": "ceo_reject_to_pool",
|
||||
"roles": [
|
||||
"ceo"
|
||||
],
|
||||
"roles": ["ceo"],
|
||||
"source": "awaiting_ceo_approval",
|
||||
"target": "pending"
|
||||
},
|
||||
{
|
||||
"action": "docs_complete",
|
||||
"roles": [
|
||||
"documenter"
|
||||
],
|
||||
"roles": ["documenter"],
|
||||
"source": "awaiting_documentation",
|
||||
"target": "awaiting_pm_review"
|
||||
},
|
||||
{
|
||||
"action": "cancel",
|
||||
"roles": [
|
||||
"cell_pm",
|
||||
"ceo",
|
||||
"main_pm"
|
||||
],
|
||||
"roles": ["cell_pm", "ceo", "main_pm"],
|
||||
"source": "awaiting_documentation",
|
||||
"target": "cancelled"
|
||||
},
|
||||
{
|
||||
"action": "claim",
|
||||
"roles": [
|
||||
"documenter"
|
||||
],
|
||||
"roles": ["documenter"],
|
||||
"source": "awaiting_documentation",
|
||||
"target": "claimed"
|
||||
},
|
||||
{
|
||||
"action": "escalate_to_ceo",
|
||||
"roles": [
|
||||
"head_marketing",
|
||||
"main_pm",
|
||||
"product_owner"
|
||||
],
|
||||
"roles": ["head_marketing", "main_pm", "product_owner"],
|
||||
"source": "awaiting_pm_review",
|
||||
"target": "awaiting_ceo_approval"
|
||||
},
|
||||
{
|
||||
"action": "cancel",
|
||||
"roles": [
|
||||
"cell_pm",
|
||||
"ceo",
|
||||
"main_pm"
|
||||
],
|
||||
"roles": ["cell_pm", "ceo", "main_pm"],
|
||||
"source": "awaiting_pm_review",
|
||||
"target": "cancelled"
|
||||
},
|
||||
{
|
||||
"action": "complete",
|
||||
"roles": [
|
||||
"cell_pm",
|
||||
"main_pm"
|
||||
],
|
||||
"roles": ["cell_pm", "main_pm"],
|
||||
"source": "awaiting_pm_review",
|
||||
"target": "completed"
|
||||
},
|
||||
{
|
||||
"action": "request_changes",
|
||||
"roles": [
|
||||
"cell_pm",
|
||||
"main_pm"
|
||||
],
|
||||
"roles": ["cell_pm", "main_pm"],
|
||||
"source": "awaiting_pm_review",
|
||||
"target": "needs_revision"
|
||||
},
|
||||
{
|
||||
"action": "pr_pass",
|
||||
"roles": [
|
||||
"pr_reviewer"
|
||||
],
|
||||
"roles": ["pr_reviewer"],
|
||||
"source": "awaiting_pr_review",
|
||||
"target": "awaiting_pm_review"
|
||||
},
|
||||
{
|
||||
"action": "cancel",
|
||||
"roles": [
|
||||
"cell_pm",
|
||||
"ceo",
|
||||
"main_pm"
|
||||
],
|
||||
"roles": ["cell_pm", "ceo", "main_pm"],
|
||||
"source": "awaiting_pr_review",
|
||||
"target": "cancelled"
|
||||
},
|
||||
{
|
||||
"action": "claim",
|
||||
"roles": [
|
||||
"pr_reviewer"
|
||||
],
|
||||
"roles": ["pr_reviewer"],
|
||||
"source": "awaiting_pr_review",
|
||||
"target": "claimed"
|
||||
},
|
||||
{
|
||||
"action": "pr_fail",
|
||||
"roles": [
|
||||
"pr_reviewer"
|
||||
],
|
||||
"roles": ["pr_reviewer"],
|
||||
"source": "awaiting_pr_review",
|
||||
"target": "needs_revision"
|
||||
},
|
||||
{
|
||||
"action": "qa_pass",
|
||||
"roles": [
|
||||
"qa"
|
||||
],
|
||||
"roles": ["qa"],
|
||||
"source": "awaiting_qa",
|
||||
"target": "awaiting_documentation"
|
||||
},
|
||||
{
|
||||
"action": "cancel",
|
||||
"roles": [
|
||||
"cell_pm",
|
||||
"ceo",
|
||||
"main_pm"
|
||||
],
|
||||
"roles": ["cell_pm", "ceo", "main_pm"],
|
||||
"source": "awaiting_qa",
|
||||
"target": "cancelled"
|
||||
},
|
||||
{
|
||||
"action": "claim",
|
||||
"roles": [
|
||||
"qa"
|
||||
],
|
||||
"roles": ["qa"],
|
||||
"source": "awaiting_qa",
|
||||
"target": "claimed"
|
||||
},
|
||||
{
|
||||
"action": "qa_fail",
|
||||
"roles": [
|
||||
"qa"
|
||||
],
|
||||
"roles": ["qa"],
|
||||
"source": "awaiting_qa",
|
||||
"target": "needs_revision"
|
||||
},
|
||||
{
|
||||
"action": "cancel",
|
||||
"roles": [
|
||||
"cell_pm",
|
||||
"ceo",
|
||||
"main_pm"
|
||||
],
|
||||
"roles": ["cell_pm", "ceo", "main_pm"],
|
||||
"source": "backlog",
|
||||
"target": "cancelled"
|
||||
},
|
||||
@@ -623,21 +430,13 @@
|
||||
},
|
||||
{
|
||||
"action": "escalate_to_ceo",
|
||||
"roles": [
|
||||
"head_marketing",
|
||||
"main_pm",
|
||||
"product_owner"
|
||||
],
|
||||
"roles": ["head_marketing", "main_pm", "product_owner"],
|
||||
"source": "blocked",
|
||||
"target": "awaiting_ceo_approval"
|
||||
},
|
||||
{
|
||||
"action": "cancel",
|
||||
"roles": [
|
||||
"cell_pm",
|
||||
"ceo",
|
||||
"main_pm"
|
||||
],
|
||||
"roles": ["cell_pm", "ceo", "main_pm"],
|
||||
"source": "blocked",
|
||||
"target": "cancelled"
|
||||
},
|
||||
@@ -655,11 +454,7 @@
|
||||
},
|
||||
{
|
||||
"action": "cancel",
|
||||
"roles": [
|
||||
"cell_pm",
|
||||
"ceo",
|
||||
"main_pm"
|
||||
],
|
||||
"roles": ["cell_pm", "ceo", "main_pm"],
|
||||
"source": "claimed",
|
||||
"target": "cancelled"
|
||||
},
|
||||
@@ -689,19 +484,13 @@
|
||||
},
|
||||
{
|
||||
"action": "cancel",
|
||||
"roles": [
|
||||
"cell_pm",
|
||||
"ceo",
|
||||
"main_pm"
|
||||
],
|
||||
"roles": ["cell_pm", "ceo", "main_pm"],
|
||||
"source": "in_progress",
|
||||
"target": "cancelled"
|
||||
},
|
||||
{
|
||||
"action": "pr_review_done",
|
||||
"roles": [
|
||||
"pr_reviewer"
|
||||
],
|
||||
"roles": ["pr_reviewer"],
|
||||
"source": "in_progress",
|
||||
"target": "completed"
|
||||
},
|
||||
@@ -719,11 +508,7 @@
|
||||
},
|
||||
{
|
||||
"action": "cancel",
|
||||
"roles": [
|
||||
"cell_pm",
|
||||
"ceo",
|
||||
"main_pm"
|
||||
],
|
||||
"roles": ["cell_pm", "ceo", "main_pm"],
|
||||
"source": "needs_revision",
|
||||
"target": "cancelled"
|
||||
},
|
||||
@@ -735,11 +520,7 @@
|
||||
},
|
||||
{
|
||||
"action": "cancel",
|
||||
"roles": [
|
||||
"cell_pm",
|
||||
"ceo",
|
||||
"main_pm"
|
||||
],
|
||||
"roles": ["cell_pm", "ceo", "main_pm"],
|
||||
"source": "paused",
|
||||
"target": "cancelled"
|
||||
},
|
||||
@@ -751,11 +532,7 @@
|
||||
},
|
||||
{
|
||||
"action": "cancel",
|
||||
"roles": [
|
||||
"cell_pm",
|
||||
"ceo",
|
||||
"main_pm"
|
||||
],
|
||||
"roles": ["cell_pm", "ceo", "main_pm"],
|
||||
"source": "pending",
|
||||
"target": "cancelled"
|
||||
},
|
||||
@@ -773,11 +550,7 @@
|
||||
},
|
||||
{
|
||||
"action": "cancel",
|
||||
"roles": [
|
||||
"cell_pm",
|
||||
"ceo",
|
||||
"main_pm"
|
||||
],
|
||||
"roles": ["cell_pm", "ceo", "main_pm"],
|
||||
"source": "verifying",
|
||||
"target": "cancelled"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, act } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import type { ReactNode } from "react";
|
||||
import { PageRefreshProvider } from "@/components/providers";
|
||||
import type {
|
||||
@@ -47,6 +48,12 @@ vi.mock("@/hooks/use-websocket", () => ({
|
||||
useA2ALiveStream,
|
||||
}));
|
||||
|
||||
// The xl:+ context pane's linked-task summary fetches via useTask — stub it
|
||||
// so this suite doesn't need a real QueryClientProvider.
|
||||
vi.mock("@/hooks/use-tasks", () => ({
|
||||
useTask: () => ({ data: undefined, isLoading: false }),
|
||||
}));
|
||||
|
||||
vi.mock("@tanstack/react-query", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@tanstack/react-query")>();
|
||||
return {
|
||||
@@ -144,6 +151,7 @@ describe("A2APage", () => {
|
||||
lastMessage: null,
|
||||
a2aMessages: [],
|
||||
isConnected: true,
|
||||
state: "connected",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -201,6 +209,7 @@ describe("A2APage", () => {
|
||||
},
|
||||
a2aMessages: [],
|
||||
isConnected: true,
|
||||
state: "connected",
|
||||
});
|
||||
render(withPageRefresh(<A2APage />));
|
||||
expect(invalidateQueries).toHaveBeenCalledWith({
|
||||
@@ -223,6 +232,7 @@ describe("A2APage", () => {
|
||||
},
|
||||
a2aMessages: [],
|
||||
isConnected: false,
|
||||
state: "disconnected",
|
||||
});
|
||||
render(withPageRefresh(<A2APage />));
|
||||
expect(invalidateQueries).toHaveBeenCalledWith({
|
||||
@@ -273,6 +283,7 @@ describe("A2APage", () => {
|
||||
lastMessage: null,
|
||||
a2aMessages: [],
|
||||
isConnected: false,
|
||||
state: "disconnected",
|
||||
});
|
||||
const { rerender } = render(withPageRefresh(<A2APage />));
|
||||
// No invalidation while offline.
|
||||
@@ -285,6 +296,7 @@ describe("A2APage", () => {
|
||||
lastMessage: null,
|
||||
a2aMessages: [],
|
||||
isConnected: true,
|
||||
state: "connected",
|
||||
});
|
||||
act(() => {
|
||||
rerender(withPageRefresh(<A2APage />));
|
||||
@@ -302,10 +314,117 @@ describe("A2APage", () => {
|
||||
lastMessage: null,
|
||||
a2aMessages: [],
|
||||
isConnected: true,
|
||||
state: "connected",
|
||||
});
|
||||
render(withPageRefresh(<A2APage />));
|
||||
expect(invalidateQueries).not.toHaveBeenCalledWith({
|
||||
queryKey: a2aLiveKeys.all,
|
||||
});
|
||||
});
|
||||
|
||||
it("renders the filter trigger above the switchboard/list content", () => {
|
||||
useA2AAdminPairs.mockReturnValue({
|
||||
data: { items: [buildPair()], total: 1 },
|
||||
isLoading: false,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
render(withPageRefresh(<A2APage />));
|
||||
expect(
|
||||
screen.getByRole("button", { name: /^Filters$/ }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("narrows the switchboard's pairs by a selected agent", async () => {
|
||||
const user = userEvent.setup();
|
||||
useA2AAdminPairs.mockReturnValue({
|
||||
data: {
|
||||
items: [
|
||||
buildPair(),
|
||||
buildPair({
|
||||
agent_a: "auditor",
|
||||
agent_b: "product-owner",
|
||||
group_key: "board",
|
||||
conversation_id: null,
|
||||
last_message_at: null,
|
||||
message_count: 0,
|
||||
}),
|
||||
],
|
||||
total: 2,
|
||||
},
|
||||
isLoading: false,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
render(withPageRefresh(<A2APage />));
|
||||
expect(screen.getByText(/Backend Cell/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/^Board$/)).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /^Filters$/ }));
|
||||
await user.click(screen.getByRole("checkbox", { name: "Auditor" }));
|
||||
|
||||
expect(screen.queryByText(/Backend Cell/)).not.toBeInTheDocument();
|
||||
expect(screen.getByText(/^Board$/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("narrows the classic list's conversations by a selected agent", async () => {
|
||||
const user = userEvent.setup();
|
||||
useA2AConversations.mockReturnValue({
|
||||
data: {
|
||||
items: [
|
||||
buildConversation(),
|
||||
buildConversation({
|
||||
id: "conv-2",
|
||||
agent_a: "ux-dev-1",
|
||||
agent_b: "ux-qa",
|
||||
topic: "Design review",
|
||||
}),
|
||||
],
|
||||
total: 2,
|
||||
},
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
render(withPageRefresh(<A2APage />));
|
||||
fireEvent.click(screen.getByTitle("Classic conversation list"));
|
||||
expect(screen.getByText("QA handoff")).toBeInTheDocument();
|
||||
expect(screen.getByText("Design review")).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /^Filters$/ }));
|
||||
await user.click(screen.getByRole("checkbox", { name: "UX/UI Dev 1" }));
|
||||
|
||||
expect(screen.queryByText("QA handoff")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Design review")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("narrows the classic list's conversations by task id fragment", async () => {
|
||||
const user = userEvent.setup();
|
||||
useA2AConversations.mockReturnValue({
|
||||
data: {
|
||||
items: [
|
||||
buildConversation({
|
||||
task_id: "11111111-2222-3333-4444-555555555555",
|
||||
}),
|
||||
buildConversation({
|
||||
id: "conv-2",
|
||||
topic: "Design review",
|
||||
task_id: "99999999-8888-7777-6666-555555555555",
|
||||
}),
|
||||
],
|
||||
total: 2,
|
||||
},
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
render(withPageRefresh(<A2APage />));
|
||||
fireEvent.click(screen.getByTitle("Classic conversation list"));
|
||||
expect(screen.getByText("QA handoff")).toBeInTheDocument();
|
||||
expect(screen.getByText("Design review")).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /^Filters$/ }));
|
||||
await user.type(screen.getByLabelText("Task id fragment"), "11111111");
|
||||
|
||||
expect(screen.getByText("QA handoff")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Design review")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,13 +22,27 @@ import { A2AConversationList } from "@/components/a2a/a2a-conversation-list";
|
||||
import { A2ASwitchboard } from "@/components/a2a/a2a-switchboard";
|
||||
import { A2ATranscript } from "@/components/a2a/a2a-transcript";
|
||||
import { A2AReplyComposer } from "@/components/a2a/a2a-reply-composer";
|
||||
import { A2AFilterBar } from "@/components/a2a/a2a-filter-bar";
|
||||
import { A2AContextPane } from "@/components/a2a/a2a-context-pane";
|
||||
import {
|
||||
A2AConnectionBadge,
|
||||
A2AConnectionBanner,
|
||||
} from "@/components/a2a/a2a-connection-badge";
|
||||
import { latestPulseTimestamps } from "@/components/a2a/a2a-switchboard-utils";
|
||||
import {
|
||||
distinctA2AAgents,
|
||||
filterConversations,
|
||||
filterPairs,
|
||||
EMPTY_A2A_FILTERS,
|
||||
type A2AFilters,
|
||||
} from "@/components/a2a/a2a-filter-utils";
|
||||
import type { AdminPairSummary } from "@/lib/api/a2a";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { OfflineState } from "@/components/ui/offline-state";
|
||||
import { useUIStore } from "@/store";
|
||||
import { getAgentDisplayName } from "@/lib/agent-utils";
|
||||
import { lastSenderOf } from "@/components/a2a/a2a-utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -37,6 +51,8 @@ import {
|
||||
LayoutGrid,
|
||||
List as ListIcon,
|
||||
MessagesSquare,
|
||||
PanelRightClose,
|
||||
PanelRightOpen,
|
||||
Radio,
|
||||
} from "lucide-react";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
@@ -48,23 +64,6 @@ interface PeekedPair {
|
||||
agent_b: string;
|
||||
}
|
||||
|
||||
function EmptyPanel({
|
||||
icon: Icon,
|
||||
message,
|
||||
}: {
|
||||
icon: typeof MessagesSquare;
|
||||
message: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center text-muted-foreground">
|
||||
<div className="text-center p-4">
|
||||
<Icon className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
||||
<p className="text-sm">{message}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function A2APageContent() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
@@ -80,6 +79,25 @@ function A2APageContent() {
|
||||
// shown as an explicit "no A2A yet" state in the drill-in panel.
|
||||
const [peekedPair, setPeekedPair] = useState<PeekedPair | null>(null);
|
||||
|
||||
// Filter panel: Agent, Task (id fragment + no-linked-task), Status, Date
|
||||
// range — narrows both the switchboard's pairs (Agent only) and the
|
||||
// list's conversations (all four), per the design doc's per-view rules.
|
||||
const [filters, setFilters] = useState<A2AFilters>(EMPTY_A2A_FILTERS);
|
||||
|
||||
// xl:+ context pane collapse, persisted via the shared UI store — same
|
||||
// idiom as sidebar/theme preferences (design doc §1).
|
||||
const contextOpen = useUIStore((s) => s.a2aContextOpen);
|
||||
const toggleContext = useUIStore((s) => s.toggleA2AContext);
|
||||
|
||||
// Reconnecting/disconnected banner strip, dismissable per occurrence — it
|
||||
// reappears the next time the connection drops (design doc §3). Render-phase
|
||||
// reset (compared against the previous connectionState, same idiom as
|
||||
// A2APairCard's usePulseFlash) rather than an effect.
|
||||
const [bannerDismissed, setBannerDismissed] = useState(false);
|
||||
const [lastConnectionState, setLastConnectionState] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const {
|
||||
data: conversationData,
|
||||
isLoading: loadingConversations,
|
||||
@@ -94,6 +112,7 @@ function A2APageContent() {
|
||||
const {
|
||||
data: messagesData,
|
||||
isLoading: loadingMessages,
|
||||
error: messagesError,
|
||||
refetch: refetchMessages,
|
||||
} = useA2AMessages(selectedId);
|
||||
|
||||
@@ -127,7 +146,12 @@ function A2APageContent() {
|
||||
// `a2a.message` frame. Invalidate-on-frame (the session-detail idiom) — the
|
||||
// frame's excerpt is capped by design, so REST stays the source of truth and
|
||||
// react-query refetches the affected queries.
|
||||
const { lastMessage, a2aMessages, isConnected } = useA2ALiveStream();
|
||||
const {
|
||||
lastMessage,
|
||||
a2aMessages,
|
||||
isConnected,
|
||||
state: connectionState,
|
||||
} = useA2ALiveStream();
|
||||
useEffect(() => {
|
||||
if (lastMessage?.type !== "a2a.message") return;
|
||||
queryClient.invalidateQueries({ queryKey: a2aLiveKeys.conversations });
|
||||
@@ -139,6 +163,18 @@ function A2APageContent() {
|
||||
}
|
||||
}, [lastMessage, queryClient, selectedId]);
|
||||
|
||||
// Re-arm the dismissable banner the next time the connection actually
|
||||
// drops, rather than leaving it dismissed forever after the first hiccup.
|
||||
if (connectionState !== lastConnectionState) {
|
||||
setLastConnectionState(connectionState);
|
||||
if (
|
||||
connectionState !== "reconnecting" &&
|
||||
connectionState !== "disconnected"
|
||||
) {
|
||||
setBannerDismissed(false);
|
||||
}
|
||||
}
|
||||
|
||||
// On /ws/system reconnect (false → true) the A2A list is stale — events
|
||||
// missed during the disconnect. Invalidate the a2a query family so
|
||||
// react-query refetches. Initial mount with isConnected=true does NOT
|
||||
@@ -158,6 +194,10 @@ function A2APageContent() {
|
||||
() => latestPulseTimestamps(a2aMessages, pairs),
|
||||
[a2aMessages, pairs],
|
||||
);
|
||||
const filteredPairs = useMemo(
|
||||
() => filterPairs(pairs, filters),
|
||||
[pairs, filters],
|
||||
);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(id: string) => {
|
||||
@@ -186,7 +226,18 @@ function A2APageContent() {
|
||||
[handleSelect, router, searchParams],
|
||||
);
|
||||
|
||||
const conversations = conversationData?.items ?? [];
|
||||
const conversations = useMemo(
|
||||
() => conversationData?.items ?? [],
|
||||
[conversationData],
|
||||
);
|
||||
const filteredConversations = useMemo(
|
||||
() => filterConversations(conversations, filters),
|
||||
[conversations, filters],
|
||||
);
|
||||
const agentOptions = useMemo(
|
||||
() => distinctA2AAgents(conversations, pairs),
|
||||
[conversations, pairs],
|
||||
);
|
||||
const selected = conversations.find((c) => c.id === selectedId) ?? null;
|
||||
const messages = messagesData?.items ?? [];
|
||||
const lastSender = lastSenderOf(messages);
|
||||
@@ -220,19 +271,24 @@ function A2APageContent() {
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"h-2 w-2 rounded-full",
|
||||
isConnected
|
||||
? "bg-emerald-500 animate-pulse"
|
||||
: "bg-muted-foreground/40",
|
||||
)}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{isConnected ? "Live" : "Offline"}
|
||||
</span>
|
||||
</div>
|
||||
<A2AConnectionBadge state={connectionState} />
|
||||
{/* Context pane never appears below xl — its toggle is hidden
|
||||
there too, matching the switchboard/list toggle's placement
|
||||
idiom (design doc §1). */}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="hidden h-7 px-2 xl:inline-flex"
|
||||
onClick={toggleContext}
|
||||
title={contextOpen ? "Hide context panel" : "Show context panel"}
|
||||
>
|
||||
{contextOpen ? (
|
||||
<PanelRightClose className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<PanelRightOpen className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -262,6 +318,7 @@ function A2APageContent() {
|
||||
<Card
|
||||
className={cn(
|
||||
"col-span-12 flex-col overflow-hidden lg:col-span-4 lg:flex",
|
||||
contextOpen && "xl:col-span-3",
|
||||
onDetailLevel ? "hidden" : "flex",
|
||||
)}
|
||||
>
|
||||
@@ -296,10 +353,16 @@ function A2APageContent() {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<A2AFilterBar
|
||||
filters={filters}
|
||||
onFiltersChange={setFilters}
|
||||
agentOptions={agentOptions}
|
||||
view={view}
|
||||
/>
|
||||
<div className="flex-1 overflow-hidden -mx-3">
|
||||
{view === "switchboard" ? (
|
||||
<A2ASwitchboard
|
||||
pairs={pairs}
|
||||
pairs={filteredPairs}
|
||||
pulses={pulses}
|
||||
selectedConversationId={selectedId}
|
||||
isLoading={loadingPairs}
|
||||
@@ -307,10 +370,11 @@ function A2APageContent() {
|
||||
/>
|
||||
) : (
|
||||
<A2AConversationList
|
||||
conversations={conversations}
|
||||
conversations={filteredConversations}
|
||||
selectedId={selectedId}
|
||||
onSelect={handleSelect}
|
||||
isLoading={loadingConversations}
|
||||
pulses={pulses}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -321,64 +385,12 @@ function A2APageContent() {
|
||||
<Card
|
||||
className={cn(
|
||||
"col-span-12 flex-col overflow-hidden lg:col-span-8 lg:flex",
|
||||
contextOpen && "xl:col-span-6",
|
||||
onDetailLevel ? "flex" : "hidden",
|
||||
)}
|
||||
>
|
||||
<CardContent className="p-3 flex flex-col h-full">
|
||||
{selected ? (
|
||||
<>
|
||||
<div className="flex items-center gap-2 mb-3 pb-2 border-b flex-wrap">
|
||||
<MessagesSquare className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">
|
||||
{getAgentDisplayName(selected.agent_a)}
|
||||
{" ↔ "}
|
||||
{getAgentDisplayName(selected.agent_b)}
|
||||
</span>
|
||||
<Badge
|
||||
variant={
|
||||
selected.status === "active" ? "default" : "secondary"
|
||||
}
|
||||
className="text-xs"
|
||||
>
|
||||
{selected.status}
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground ml-auto">
|
||||
{selected.message_count} msgs · updated{" "}
|
||||
{formatDistanceToNow(new Date(selected.updated_at))} ago
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 overflow-hidden -mx-3">
|
||||
<A2ATranscript
|
||||
messages={messages}
|
||||
isLoading={loadingMessages}
|
||||
/>
|
||||
</div>
|
||||
{/* Reply composer. The backend's reply route rejects with
|
||||
400 exactly when the watched conversation has no task
|
||||
link (replies ride the gateway send path, which requires
|
||||
one), so a task-less conversation is read-only — say why
|
||||
instead of letting the send bounce. Status does NOT gate
|
||||
the composer: the CEO's reply lands in their own direct
|
||||
thread with the participant, not in this conversation. */}
|
||||
<div className="shrink-0 border-t -mx-3">
|
||||
{selected.task_id ? (
|
||||
<A2AReplyComposer
|
||||
key={selected.id}
|
||||
conversationId={selected.id}
|
||||
agentA={selected.agent_a}
|
||||
agentB={selected.agent_b}
|
||||
lastSender={lastSender}
|
||||
/>
|
||||
) : (
|
||||
<div className="p-4 text-center text-sm text-muted-foreground">
|
||||
This conversation has no linked task, so a reply
|
||||
can't be sent (A2A messages are always scoped to
|
||||
a task).
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : peekedPair ? (
|
||||
{peekedPair ? (
|
||||
<div className="h-full flex items-center justify-center text-muted-foreground">
|
||||
<div className="text-center p-4 max-w-xs">
|
||||
<MessagesSquare className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
||||
@@ -390,13 +402,113 @@ function A2APageContent() {
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<EmptyPanel
|
||||
icon={MessagesSquare}
|
||||
message="Select a conversation to watch it live"
|
||||
/>
|
||||
<>
|
||||
{selected && (
|
||||
<div className="flex items-center gap-2 mb-3 pb-2 border-b flex-wrap">
|
||||
<MessagesSquare className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">
|
||||
{getAgentDisplayName(selected.agent_a)}
|
||||
{" ↔ "}
|
||||
{getAgentDisplayName(selected.agent_b)}
|
||||
</span>
|
||||
<Badge
|
||||
variant={
|
||||
selected.status === "active"
|
||||
? "default"
|
||||
: "secondary"
|
||||
}
|
||||
className="text-xs"
|
||||
>
|
||||
{selected.status}
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground ml-auto">
|
||||
{selected.message_count} msgs · updated{" "}
|
||||
{formatDistanceToNow(new Date(selected.updated_at))}{" "}
|
||||
ago
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{/* Scoped to the stream pane, not a full-page takeover —
|
||||
a live-connection hint, distinct from OfflineState. */}
|
||||
{(connectionState === "reconnecting" ||
|
||||
connectionState === "disconnected") &&
|
||||
!bannerDismissed && (
|
||||
<div className="-mx-3 mb-3">
|
||||
<A2AConnectionBanner
|
||||
state={connectionState}
|
||||
onDismiss={() => setBannerDismissed(true)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* All three loading/empty/error states live inside
|
||||
A2ATranscript now — the pane chrome above stays mounted
|
||||
and stable while only this area swaps (design doc §5). */}
|
||||
<div className="flex-1 overflow-hidden -mx-3">
|
||||
<A2ATranscript
|
||||
messages={messages}
|
||||
isLoading={loadingMessages}
|
||||
hasSelection={!!selected}
|
||||
error={!!messagesError}
|
||||
onRetry={() => void refetchMessages()}
|
||||
/>
|
||||
</div>
|
||||
{/* Reply composer. The backend's reply route rejects with
|
||||
400 exactly when the watched conversation has no task
|
||||
link (replies ride the gateway send path, which requires
|
||||
one), so a task-less conversation is read-only — say why
|
||||
instead of letting the send bounce. Status does NOT gate
|
||||
the composer: the CEO's reply lands in their own direct
|
||||
thread with the participant, not in this conversation. */}
|
||||
{selected && (
|
||||
<div className="shrink-0 border-t -mx-3">
|
||||
{selected.task_id ? (
|
||||
<A2AReplyComposer
|
||||
key={selected.id}
|
||||
conversationId={selected.id}
|
||||
agentA={selected.agent_a}
|
||||
agentB={selected.agent_b}
|
||||
lastSender={lastSender}
|
||||
/>
|
||||
) : (
|
||||
<div className="p-4 text-center text-sm text-muted-foreground">
|
||||
This conversation has no linked task, so a reply
|
||||
can't be sent (A2A messages are always scoped
|
||||
to a task).
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Panel 3: Context (xl:+ only, dismissible) — participant
|
||||
identity cards + linked-task summary, read-only (design doc
|
||||
§1). */}
|
||||
{contextOpen && (
|
||||
<Card className="hidden xl:col-span-3 xl:flex xl:flex-col overflow-hidden">
|
||||
<CardContent className="p-0 flex-1 overflow-y-auto">
|
||||
{selected ? (
|
||||
<A2AContextPane
|
||||
agentA={selected.agent_a}
|
||||
agentB={selected.agent_b}
|
||||
taskId={selected.task_id}
|
||||
/>
|
||||
) : peekedPair ? (
|
||||
<A2AContextPane
|
||||
agentA={peekedPair.agent_a}
|
||||
agentB={peekedPair.agent_b}
|
||||
taskId={null}
|
||||
/>
|
||||
) : (
|
||||
<div className="h-full flex items-center justify-center text-muted-foreground p-4 text-center text-sm">
|
||||
Select a conversation to see participant details
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import type { ConnectionState } from "@/lib/websocket/connection";
|
||||
import {
|
||||
A2AConnectionBadge,
|
||||
A2AConnectionBanner,
|
||||
} from "../a2a-connection-badge";
|
||||
|
||||
describe("A2AConnectionBadge", () => {
|
||||
it.each([
|
||||
["connected", "Live"],
|
||||
["connecting", "Connecting…"],
|
||||
["reconnecting", "Reconnecting…"],
|
||||
["disconnected", "Offline"],
|
||||
] satisfies [ConnectionState, string][])(
|
||||
"renders a distinct label for %s",
|
||||
(state, label) => {
|
||||
render(<A2AConnectionBadge state={state} />);
|
||||
expect(screen.getByText(label)).toBeInTheDocument();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("A2AConnectionBanner", () => {
|
||||
it("reads 'Reconnecting' for the reconnecting state", () => {
|
||||
render(<A2AConnectionBanner state="reconnecting" onDismiss={vi.fn()} />);
|
||||
expect(
|
||||
screen.getByText(/Reconnecting — messages may be out of date/),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("reads 'Disconnected' for the disconnected state", () => {
|
||||
render(<A2AConnectionBanner state="disconnected" onDismiss={vi.fn()} />);
|
||||
expect(
|
||||
screen.getByText(/Disconnected — reconnecting automatically/),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("dismiss button fires onDismiss", () => {
|
||||
const onDismiss = vi.fn();
|
||||
render(<A2AConnectionBanner state="disconnected" onDismiss={onDismiss} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Dismiss" }));
|
||||
expect(onDismiss).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { TaskStatus, Team, TaskType, type Task } from "@/types";
|
||||
|
||||
function buildTask(overrides: Partial<Task> = {}): Task {
|
||||
return {
|
||||
id: "task-1",
|
||||
title: "Ship the context pane",
|
||||
description: "",
|
||||
acceptance_criteria: [],
|
||||
status: TaskStatus.IN_PROGRESS,
|
||||
priority: 1,
|
||||
project_id: "proj-1",
|
||||
task_type: TaskType.CODE,
|
||||
team: Team.FRONTEND,
|
||||
assigned_to: null,
|
||||
parent_task_id: null,
|
||||
branch_name: null,
|
||||
pr_number: null,
|
||||
pr_url: null,
|
||||
created_at: "2026-07-02T10:00:00Z",
|
||||
updated_at: "2026-07-02T10:00:00Z",
|
||||
...overrides,
|
||||
} as Task;
|
||||
}
|
||||
|
||||
let mockTask: Task | undefined;
|
||||
let mockIsLoading = false;
|
||||
|
||||
vi.mock("@/hooks/use-tasks", () => ({
|
||||
useTask: () => ({ data: mockTask, isLoading: mockIsLoading }),
|
||||
}));
|
||||
|
||||
import { A2AContextPane } from "../a2a-context-pane";
|
||||
|
||||
describe("A2AContextPane", () => {
|
||||
it("renders both participants' identity cards linking to /agents/{slug}", () => {
|
||||
mockTask = undefined;
|
||||
mockIsLoading = false;
|
||||
render(<A2AContextPane agentA="be-dev-1" agentB="be-qa" taskId={null} />);
|
||||
expect(screen.getByText("Backend Dev 1")).toBeInTheDocument();
|
||||
expect(screen.getByText("Backend QA")).toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: /Backend Dev 1/ })).toHaveAttribute(
|
||||
"href",
|
||||
"/agents/be-dev-1",
|
||||
);
|
||||
});
|
||||
|
||||
it("shows the no-task hint when the conversation has no linked task", () => {
|
||||
mockTask = undefined;
|
||||
mockIsLoading = false;
|
||||
render(<A2AContextPane agentA="be-dev-1" agentB="be-qa" taskId={null} />);
|
||||
expect(
|
||||
screen.getByText("This conversation has no linked task"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the linked task's title, status, and a View task link", () => {
|
||||
mockTask = buildTask({ title: "Ship the context pane" });
|
||||
mockIsLoading = false;
|
||||
render(<A2AContextPane agentA="be-dev-1" agentB="be-qa" taskId="task-1" />);
|
||||
expect(screen.getByText("Ship the context pane")).toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: /View task/ })).toHaveAttribute(
|
||||
"href",
|
||||
"/tasks/task-1",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -30,12 +30,16 @@ describe("A2AConversationList", () => {
|
||||
selectedId={null}
|
||||
onSelect={vi.fn()}
|
||||
isLoading={false}
|
||||
pulses={{}}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Participants via getAgentDisplayName ("{a} <-> {b}").
|
||||
expect(screen.getByText(/Backend Dev 1/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Backend QA/)).toBeInTheDocument();
|
||||
// Both participants get an avatar, matching A2APairCard's PairAvatar.
|
||||
expect(screen.getByTitle("Backend Dev 1")).toBeInTheDocument();
|
||||
expect(screen.getByTitle("Backend QA")).toBeInTheDocument();
|
||||
// Topic, preview, message count, relative timestamp.
|
||||
expect(screen.getByText("QA handoff")).toBeInTheDocument();
|
||||
expect(
|
||||
@@ -61,6 +65,7 @@ describe("A2AConversationList", () => {
|
||||
selectedId={null}
|
||||
onSelect={onSelect}
|
||||
isLoading={false}
|
||||
pulses={{}}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
@@ -75,6 +80,7 @@ describe("A2AConversationList", () => {
|
||||
selectedId={null}
|
||||
onSelect={onSelect}
|
||||
isLoading={false}
|
||||
pulses={{}}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByRole("link", { name: /Task 11111111/ }));
|
||||
@@ -88,8 +94,25 @@ describe("A2AConversationList", () => {
|
||||
selectedId={null}
|
||||
onSelect={vi.fn()}
|
||||
isLoading={false}
|
||||
pulses={{}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText(/No A2A conversations yet/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("flashes a row hot when its pair's pulse key matches (same key as the switchboard)", () => {
|
||||
render(
|
||||
<A2AConversationList
|
||||
conversations={[buildConversation()]}
|
||||
selectedId={null}
|
||||
onSelect={vi.fn()}
|
||||
isLoading={false}
|
||||
pulses={{ "be-dev-1|be-qa": 1700000000000 }}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByTestId("conversation-row")).toHaveAttribute(
|
||||
"data-pulsing",
|
||||
"true",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { A2AFilterBar } from "../a2a-filter-bar";
|
||||
import { EMPTY_A2A_FILTERS, type A2AFilters } from "../a2a-filter-utils";
|
||||
|
||||
function renderBar(
|
||||
overrides: Partial<Omit<Parameters<typeof A2AFilterBar>[0], "filters">> & {
|
||||
filters?: A2AFilters;
|
||||
} = {},
|
||||
) {
|
||||
const onFiltersChange = vi.fn();
|
||||
const { filters, ...rest } = overrides;
|
||||
render(
|
||||
<A2AFilterBar
|
||||
filters={filters ?? EMPTY_A2A_FILTERS}
|
||||
onFiltersChange={onFiltersChange}
|
||||
agentOptions={["be-dev-1", "be-qa"]}
|
||||
view="list"
|
||||
{...rest}
|
||||
/>,
|
||||
);
|
||||
return { onFiltersChange };
|
||||
}
|
||||
|
||||
describe("A2AFilterBar", () => {
|
||||
it("renders a collapsed trigger with no active-count badge by default", () => {
|
||||
renderBar();
|
||||
expect(
|
||||
screen.getByRole("button", { name: /^Filters$/ }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the active-count badge on the trigger when filters are set", () => {
|
||||
renderBar({ filters: { ...EMPTY_A2A_FILTERS, agents: ["be-dev-1"] } });
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Filters · 1" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens the popover and renders the Agent checkbox list", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderBar();
|
||||
await user.click(screen.getByRole("button", { name: /^Filters$/ }));
|
||||
expect(screen.getByText("Backend Dev 1")).toBeInTheDocument();
|
||||
expect(screen.getByText("Backend QA")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("fires onFiltersChange when an Agent checkbox is toggled", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onFiltersChange } = renderBar();
|
||||
await user.click(screen.getByRole("button", { name: /^Filters$/ }));
|
||||
await user.click(screen.getByRole("checkbox", { name: "Backend Dev 1" }));
|
||||
expect(onFiltersChange).toHaveBeenCalledWith({
|
||||
...EMPTY_A2A_FILTERS,
|
||||
agents: ["be-dev-1"],
|
||||
});
|
||||
});
|
||||
|
||||
it("renders the Task id-fragment input and the No linked task toggle", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderBar();
|
||||
await user.click(screen.getByRole("button", { name: /^Filters$/ }));
|
||||
expect(screen.getByLabelText("Task id fragment")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("checkbox", { name: "No linked task" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("fires onFiltersChange when the task id fragment is typed", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onFiltersChange } = renderBar();
|
||||
await user.click(screen.getByRole("button", { name: /^Filters$/ }));
|
||||
await user.type(screen.getByLabelText("Task id fragment"), "a");
|
||||
expect(onFiltersChange).toHaveBeenCalledWith({
|
||||
...EMPTY_A2A_FILTERS,
|
||||
taskIdFragment: "a",
|
||||
});
|
||||
});
|
||||
|
||||
it("renders Status toggle buttons and two date-range inputs", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderBar();
|
||||
await user.click(screen.getByRole("button", { name: /^Filters$/ }));
|
||||
expect(screen.getByRole("button", { name: "Active" })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Archived" }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("From date")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("To date")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("fires onFiltersChange when a status button is toggled", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onFiltersChange } = renderBar();
|
||||
await user.click(screen.getByRole("button", { name: /^Filters$/ }));
|
||||
await user.click(screen.getByRole("button", { name: "Active" }));
|
||||
expect(onFiltersChange).toHaveBeenCalledWith({
|
||||
...EMPTY_A2A_FILTERS,
|
||||
statuses: ["active"],
|
||||
});
|
||||
});
|
||||
|
||||
it("shows an inline note that Task/Status/Date apply to the List view only when view=switchboard", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderBar({ view: "switchboard" });
|
||||
await user.click(screen.getByRole("button", { name: /^Filters$/ }));
|
||||
expect(
|
||||
screen.getByText(
|
||||
"Task, Status, and Date filters apply to the Conversation List view.",
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders one chip per active filter value plus a Clear all action", () => {
|
||||
renderBar({
|
||||
filters: {
|
||||
agents: ["be-dev-1"],
|
||||
taskIdFragment: "",
|
||||
noLinkedTask: false,
|
||||
statuses: ["active"],
|
||||
dateFrom: "2026-07-01",
|
||||
dateTo: "",
|
||||
},
|
||||
});
|
||||
expect(screen.getByText("Backend Dev 1")).toBeInTheDocument();
|
||||
expect(screen.getByText("Active")).toBeInTheDocument();
|
||||
expect(screen.getByText("From 2026-07-01")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Clear all" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("removes only the matching filter when a chip's remove button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onFiltersChange } = renderBar({
|
||||
filters: {
|
||||
...EMPTY_A2A_FILTERS,
|
||||
agents: ["be-dev-1"],
|
||||
statuses: ["active"],
|
||||
},
|
||||
});
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Remove Active filter" }),
|
||||
);
|
||||
expect(onFiltersChange).toHaveBeenCalledWith({
|
||||
...EMPTY_A2A_FILTERS,
|
||||
agents: ["be-dev-1"],
|
||||
statuses: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("resets every dimension when Clear all is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onFiltersChange } = renderBar({
|
||||
filters: {
|
||||
agents: ["be-dev-1"],
|
||||
taskIdFragment: "abc",
|
||||
noLinkedTask: true,
|
||||
statuses: ["active"],
|
||||
dateFrom: "2026-07-01",
|
||||
dateTo: "2026-07-05",
|
||||
},
|
||||
});
|
||||
await user.click(screen.getByRole("button", { name: "Clear all" }));
|
||||
expect(onFiltersChange).toHaveBeenCalledWith(EMPTY_A2A_FILTERS);
|
||||
});
|
||||
|
||||
it("renders no chip row or Clear all when no filters are active", () => {
|
||||
renderBar();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Clear all" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,226 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import type { AdminConversationSummary, AdminPairSummary } from "@/lib/api/a2a";
|
||||
import {
|
||||
EMPTY_A2A_FILTERS,
|
||||
activeA2AFilterCount,
|
||||
distinctA2AAgents,
|
||||
filterConversations,
|
||||
filterPairs,
|
||||
type A2AFilters,
|
||||
} from "../a2a-filter-utils";
|
||||
|
||||
function buildConversation(
|
||||
overrides: Partial<AdminConversationSummary> = {},
|
||||
): AdminConversationSummary {
|
||||
return {
|
||||
id: "conv-1",
|
||||
agent_a: "be-dev-1",
|
||||
agent_b: "be-qa",
|
||||
topic: "QA handoff",
|
||||
task_id: null,
|
||||
status: "active",
|
||||
message_count: 3,
|
||||
last_message_at: "2026-07-02T09:00:00Z",
|
||||
last_message_preview: null,
|
||||
created_at: "2026-07-01T08:00:00Z",
|
||||
updated_at: "2026-07-02T09:00:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildPair(
|
||||
overrides: Partial<AdminPairSummary> = {},
|
||||
): AdminPairSummary {
|
||||
return {
|
||||
agent_a: "be-dev-1",
|
||||
role_a: "developer",
|
||||
team_a: "backend",
|
||||
agent_b: "be-qa",
|
||||
role_b: "qa",
|
||||
team_b: "backend",
|
||||
group_key: "cell-backend",
|
||||
conversation_id: null,
|
||||
last_message_at: null,
|
||||
message_count: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function filters(overrides: Partial<A2AFilters> = {}): A2AFilters {
|
||||
return { ...EMPTY_A2A_FILTERS, ...overrides };
|
||||
}
|
||||
|
||||
describe("filterConversations", () => {
|
||||
it("passes everything through with no active filters", () => {
|
||||
const conversations = [
|
||||
buildConversation({ status: "active" }),
|
||||
buildConversation({ id: "conv-2", status: "archived" }),
|
||||
];
|
||||
expect(filterConversations(conversations, filters())).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("narrows by selected agents (either participant matches)", () => {
|
||||
const conversations = [
|
||||
buildConversation({
|
||||
id: "conv-1",
|
||||
agent_a: "be-dev-1",
|
||||
agent_b: "be-qa",
|
||||
}),
|
||||
buildConversation({
|
||||
id: "conv-2",
|
||||
agent_a: "ux-dev-1",
|
||||
agent_b: "ux-qa",
|
||||
}),
|
||||
];
|
||||
const result = filterConversations(
|
||||
conversations,
|
||||
filters({ agents: ["be-qa"] }),
|
||||
);
|
||||
expect(result.map((c) => c.id)).toEqual(["conv-1"]);
|
||||
});
|
||||
|
||||
it("narrows by task id fragment (case-insensitive)", () => {
|
||||
const conversations = [
|
||||
buildConversation({ id: "conv-1", task_id: "abcdef01-0000" }),
|
||||
buildConversation({ id: "conv-2", task_id: "ffffffff-0000" }),
|
||||
];
|
||||
const result = filterConversations(
|
||||
conversations,
|
||||
filters({ taskIdFragment: "ABCDEF" }),
|
||||
);
|
||||
expect(result.map((c) => c.id)).toEqual(["conv-1"]);
|
||||
});
|
||||
|
||||
it("narrows to task_id === null when noLinkedTask is set", () => {
|
||||
const conversations = [
|
||||
buildConversation({ id: "conv-1", task_id: null }),
|
||||
buildConversation({ id: "conv-2", task_id: "abcdef01-0000" }),
|
||||
];
|
||||
const result = filterConversations(
|
||||
conversations,
|
||||
filters({ noLinkedTask: true }),
|
||||
);
|
||||
expect(result.map((c) => c.id)).toEqual(["conv-1"]);
|
||||
});
|
||||
|
||||
it("ORs the task fragment and no-linked-task toggle when both are set", () => {
|
||||
const conversations = [
|
||||
buildConversation({ id: "conv-1", task_id: null }),
|
||||
buildConversation({ id: "conv-2", task_id: "abcdef01-0000" }),
|
||||
buildConversation({ id: "conv-3", task_id: "zzzzzzzz-0000" }),
|
||||
];
|
||||
const result = filterConversations(
|
||||
conversations,
|
||||
filters({ taskIdFragment: "abcdef", noLinkedTask: true }),
|
||||
);
|
||||
expect(result.map((c) => c.id).sort()).toEqual(["conv-1", "conv-2"]);
|
||||
});
|
||||
|
||||
it("narrows by selected statuses", () => {
|
||||
const conversations = [
|
||||
buildConversation({ id: "conv-1", status: "active" }),
|
||||
buildConversation({ id: "conv-2", status: "archived" }),
|
||||
];
|
||||
const result = filterConversations(
|
||||
conversations,
|
||||
filters({ statuses: ["archived"] }),
|
||||
);
|
||||
expect(result.map((c) => c.id)).toEqual(["conv-2"]);
|
||||
});
|
||||
|
||||
it("narrows by date range on last_message_at at day granularity", () => {
|
||||
const conversations = [
|
||||
buildConversation({
|
||||
id: "conv-1",
|
||||
last_message_at: "2026-07-02T12:00:00Z",
|
||||
}),
|
||||
buildConversation({
|
||||
id: "conv-2",
|
||||
last_message_at: "2026-07-05T12:00:00Z",
|
||||
}),
|
||||
];
|
||||
const result = filterConversations(
|
||||
conversations,
|
||||
filters({ dateFrom: "2026-07-03", dateTo: "2026-07-06" }),
|
||||
);
|
||||
expect(result.map((c) => c.id)).toEqual(["conv-2"]);
|
||||
});
|
||||
|
||||
it("falls back to created_at for the date range when last_message_at is null", () => {
|
||||
const conversations = [
|
||||
buildConversation({
|
||||
id: "conv-1",
|
||||
last_message_at: null,
|
||||
created_at: "2026-07-02T12:00:00Z",
|
||||
}),
|
||||
];
|
||||
expect(
|
||||
filterConversations(
|
||||
conversations,
|
||||
filters({ dateFrom: "2026-07-02", dateTo: "2026-07-02" }),
|
||||
),
|
||||
).toHaveLength(1);
|
||||
expect(
|
||||
filterConversations(conversations, filters({ dateFrom: "2026-07-03" })),
|
||||
).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("filterPairs", () => {
|
||||
it("passes everything through with no active filters", () => {
|
||||
const pairs = [
|
||||
buildPair({ conversation_id: "conv-1" }),
|
||||
buildPair({ agent_a: "auditor", agent_b: "product-owner" }),
|
||||
];
|
||||
expect(filterPairs(pairs, filters())).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("narrows by selected agents only — Task/Status/Date never apply", () => {
|
||||
const pairs = [
|
||||
buildPair({ agent_a: "be-dev-1", agent_b: "be-qa" }),
|
||||
buildPair({ agent_a: "auditor", agent_b: "product-owner" }),
|
||||
];
|
||||
const result = filterPairs(
|
||||
pairs,
|
||||
filters({
|
||||
agents: ["be-dev-1"],
|
||||
statuses: ["archived"],
|
||||
dateFrom: "2099-01-01",
|
||||
}),
|
||||
);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].agent_a).toBe("be-dev-1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("distinctA2AAgents", () => {
|
||||
it("dedupes and sorts agent slugs across conversations and pairs", () => {
|
||||
const conversations = [
|
||||
buildConversation({ agent_a: "fe-qa", agent_b: "be-qa" }),
|
||||
];
|
||||
const pairs = [buildPair({ agent_a: "be-dev-1", agent_b: "be-qa" })];
|
||||
expect(distinctA2AAgents(conversations, pairs)).toEqual([
|
||||
"be-dev-1",
|
||||
"be-qa",
|
||||
"fe-qa",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("activeA2AFilterCount", () => {
|
||||
it("counts zero for the empty filter state", () => {
|
||||
expect(activeA2AFilterCount(EMPTY_A2A_FILTERS)).toBe(0);
|
||||
});
|
||||
|
||||
it("counts one entry per active chip", () => {
|
||||
expect(
|
||||
activeA2AFilterCount(
|
||||
filters({
|
||||
agents: ["be-dev-1", "be-qa"],
|
||||
statuses: ["active"],
|
||||
dateFrom: "2026-07-01",
|
||||
}),
|
||||
),
|
||||
).toBe(4);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, act } from "@testing-library/react";
|
||||
import type { AdminPairSummary } from "@/lib/api/a2a";
|
||||
import { A2APairCard } from "../a2a-pair-card";
|
||||
import { A2APairCard, PairAvatar } from "../a2a-pair-card";
|
||||
|
||||
function buildPair(
|
||||
overrides: Partial<AdminPairSummary> = {},
|
||||
@@ -51,6 +51,13 @@ describe("A2APairCard", () => {
|
||||
expect(screen.queryByText("5")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("colors each avatar by team, not a per-agent hue", () => {
|
||||
render(<PairAvatar slug="fe-dev-1" />);
|
||||
expect(screen.getByTitle("Frontend Dev 1")).toHaveClass(
|
||||
"border-violet-500/40",
|
||||
);
|
||||
});
|
||||
|
||||
it("marks the card as selected via aria-pressed", () => {
|
||||
render(
|
||||
<A2APairCard
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, act } from "@testing-library/react";
|
||||
import type { A2AChatMessage } from "@/lib/api/a2a";
|
||||
|
||||
// react-markdown is heavyweight and irrelevant here — render bodies as-is.
|
||||
@@ -75,4 +75,100 @@ describe("A2ATranscript", () => {
|
||||
screen.getByText(/No messages in this conversation yet/),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows a distinct nothing-selected hint when hasSelection is false", () => {
|
||||
render(
|
||||
<A2ATranscript messages={[]} isLoading={false} hasSelection={false} />,
|
||||
);
|
||||
expect(
|
||||
screen.getByText("Select a conversation to view messages"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText(/No messages in this conversation yet/),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the scoped error state with a working Retry button", () => {
|
||||
const onRetry = vi.fn();
|
||||
render(
|
||||
<A2ATranscript messages={[]} isLoading={false} error onRetry={onRetry} />,
|
||||
);
|
||||
expect(
|
||||
screen.getByText("Couldn't load this conversation"),
|
||||
).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
|
||||
expect(onRetry).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("error state takes precedence over the empty/nothing-selected states", () => {
|
||||
render(<A2ATranscript messages={[]} isLoading={false} error />);
|
||||
expect(
|
||||
screen.queryByText(/No messages in this conversation yet/),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("A2ATranscript new-row entrance (frame -> row fades in, then settles)", () => {
|
||||
// Deterministic rAF, same idiom as A2APairCard's pulse test.
|
||||
let rafCallback: FrameRequestCallback | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
rafCallback = null;
|
||||
vi.spyOn(window, "requestAnimationFrame").mockImplementation((cb) => {
|
||||
rafCallback = cb;
|
||||
return 1;
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("renders initially-loaded messages settled (never marked new)", () => {
|
||||
render(
|
||||
<A2ATranscript
|
||||
messages={[buildMessage({ id: "m1" })]}
|
||||
isLoading={false}
|
||||
/>,
|
||||
);
|
||||
const rows = screen.getAllByTestId("transcript-row");
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]).toHaveAttribute("data-new", "false");
|
||||
});
|
||||
|
||||
it("marks a message arriving after initial load as new, then settles it next frame", () => {
|
||||
const { rerender } = render(
|
||||
<A2ATranscript
|
||||
messages={[buildMessage({ id: "m1" })]}
|
||||
isLoading={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
rerender(
|
||||
<A2ATranscript
|
||||
messages={[
|
||||
buildMessage({ id: "m1" }),
|
||||
buildMessage({
|
||||
id: "m2",
|
||||
content: "second",
|
||||
created_at: "2026-07-02T10:05:00Z",
|
||||
}),
|
||||
]}
|
||||
isLoading={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
const rows = screen.getAllByTestId("transcript-row");
|
||||
const newRow = rows.find((r) => r.textContent?.includes("second"));
|
||||
expect(newRow).toHaveAttribute("data-new", "true");
|
||||
|
||||
act(() => {
|
||||
rafCallback?.(0);
|
||||
});
|
||||
|
||||
const settledRows = screen.getAllByTestId("transcript-row");
|
||||
for (const row of settledRows) {
|
||||
expect(row).toHaveAttribute("data-new", "false");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
connectionDotClasses,
|
||||
connectionStateLabel,
|
||||
lastSenderOf,
|
||||
pickDefaultRecipient,
|
||||
recipientOptions,
|
||||
} from "../a2a-utils";
|
||||
import type { ConnectionState } from "@/lib/websocket/connection";
|
||||
|
||||
describe("lastSenderOf", () => {
|
||||
it("returns null for an empty transcript", () => {
|
||||
@@ -52,3 +55,37 @@ describe("recipientOptions", () => {
|
||||
expect(recipientOptions("be-dev-1", "ceo")).toEqual(["be-dev-1"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("connectionStateLabel (design doc §3 — all four states distinct)", () => {
|
||||
it.each([
|
||||
["connected", "Live"],
|
||||
["connecting", "Connecting…"],
|
||||
["reconnecting", "Reconnecting…"],
|
||||
["disconnected", "Offline"],
|
||||
] satisfies [ConnectionState, string][])(
|
||||
"labels %s as %s",
|
||||
(state, label) => {
|
||||
expect(connectionStateLabel(state)).toBe(label);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("connectionDotClasses", () => {
|
||||
it("connected is static — no pulse", () => {
|
||||
expect(connectionDotClasses("connected")).not.toContain("animate-pulse");
|
||||
});
|
||||
|
||||
it("connecting and reconnecting pulse with a motion-reduce guard", () => {
|
||||
for (const state of ["connecting", "reconnecting"] as ConnectionState[]) {
|
||||
const classes = connectionDotClasses(state);
|
||||
expect(classes).toContain("animate-pulse");
|
||||
expect(classes).toContain("motion-reduce:animate-none");
|
||||
}
|
||||
});
|
||||
|
||||
it("disconnected is static and muted", () => {
|
||||
const classes = connectionDotClasses("disconnected");
|
||||
expect(classes).not.toContain("animate-pulse");
|
||||
expect(classes).toContain("muted-foreground");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
"use client";
|
||||
|
||||
import { Loader2, WifiOff, X } from "lucide-react";
|
||||
import type { ConnectionState } from "@/lib/websocket/connection";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { connectionDotClasses, connectionStateLabel } from "./a2a-utils";
|
||||
|
||||
/** Pane-header connection indicator: dot + label, plus a spinner/offline icon
|
||||
* for the connecting/reconnecting/disconnected states (design doc §3). All
|
||||
* four `ConnectionState` values render distinctly — a live-but-quiet
|
||||
* conversation must read differently from a stream that is the problem. */
|
||||
export function A2AConnectionBadge({ state }: { state: ConnectionState }) {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span
|
||||
className={cn("h-2 w-2 rounded-full", connectionDotClasses(state))}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{connectionStateLabel(state)}
|
||||
</span>
|
||||
{(state === "connecting" || state === "reconnecting") && (
|
||||
<Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />
|
||||
)}
|
||||
{state === "disconnected" && (
|
||||
<WifiOff className="h-3 w-3 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface A2AConnectionBannerProps {
|
||||
state: "reconnecting" | "disconnected";
|
||||
onDismiss: () => void;
|
||||
}
|
||||
|
||||
/** Dismissable strip above the stream pane's message list — a scoped
|
||||
* live-connection hint, not the full-page `OfflineState` (design doc §3). */
|
||||
export function A2AConnectionBanner({
|
||||
state,
|
||||
onDismiss,
|
||||
}: A2AConnectionBannerProps) {
|
||||
const isDisconnected = state === "disconnected";
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-between gap-2 border-b text-xs px-3 py-1.5",
|
||||
isDisconnected
|
||||
? "bg-destructive/10 border-destructive/30 text-destructive"
|
||||
: "bg-amber-500/10 border-amber-500/30 text-amber-700 dark:text-amber-400",
|
||||
)}
|
||||
>
|
||||
<span>
|
||||
{isDisconnected
|
||||
? "Disconnected — reconnecting automatically"
|
||||
: "Reconnecting — messages may be out of date"}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDismiss}
|
||||
aria-label="Dismiss"
|
||||
className="shrink-0 opacity-70 hover:opacity-100"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
getAgentDisplayName,
|
||||
getAgentInitials,
|
||||
getAgentTeamColor,
|
||||
TEAM_COLOR_CLASSES,
|
||||
} from "@/lib/agent-utils";
|
||||
import { useTask } from "@/hooks/use-tasks";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ListTodo, Users } from "lucide-react";
|
||||
|
||||
/** One participant's identity card — avatar, name, team badge — linking to
|
||||
* the agent's own detail page (design doc §1/§2). Read-only, same team-color
|
||||
* mapping every other identity affordance uses. */
|
||||
function IdentityCard({ slug }: { slug: string }) {
|
||||
const teamColor = getAgentTeamColor(slug);
|
||||
return (
|
||||
<Link
|
||||
href={`/agents/${slug}`}
|
||||
className="flex items-center gap-2 rounded-lg border p-2 hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"h-9 w-9 rounded-full border flex items-center justify-center shrink-0",
|
||||
TEAM_COLOR_CLASSES[teamColor],
|
||||
)}
|
||||
title={getAgentDisplayName(slug)}
|
||||
>
|
||||
<span className="text-[10px] font-bold tracking-tight">
|
||||
{getAgentInitials(slug)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium truncate">
|
||||
{getAgentDisplayName(slug)}
|
||||
</div>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn("text-[10px] mt-0.5", TEAM_COLOR_CLASSES[teamColor])}
|
||||
>
|
||||
{teamColor.replace("_", "/")}
|
||||
</Badge>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
interface A2AContextPaneProps {
|
||||
agentA: string;
|
||||
agentB: string;
|
||||
/** null when this conversation (or peeked pair) has no linked task. */
|
||||
taskId: string | null;
|
||||
}
|
||||
|
||||
/** The `xl:`+ context region: both participants' identity cards, a linked-task
|
||||
* summary, and a no-task hint when there isn't one — read-only, never a
|
||||
* second place to act on the conversation (design doc §1). */
|
||||
export function A2AContextPane({
|
||||
agentA,
|
||||
agentB,
|
||||
taskId,
|
||||
}: A2AContextPaneProps) {
|
||||
const { data: task, isLoading } = useTask(taskId ?? "");
|
||||
|
||||
return (
|
||||
<div className="p-3 space-y-4">
|
||||
<div className="flex items-center gap-2 pb-2 border-b">
|
||||
<Users className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">Context</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<IdentityCard slug={agentA} />
|
||||
<IdentityCard slug={agentB} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||
Linked task
|
||||
</div>
|
||||
{!taskId ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
This conversation has no linked task
|
||||
</p>
|
||||
) : isLoading || !task ? (
|
||||
<Skeleton className="h-16 w-full" />
|
||||
) : (
|
||||
<div className="rounded-lg border p-2.5 space-y-1.5">
|
||||
<div className="text-sm font-medium truncate">{task.title}</div>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Badge
|
||||
variant={task.status === "completed" ? "default" : "secondary"}
|
||||
className="text-xs"
|
||||
>
|
||||
{task.status}
|
||||
</Badge>
|
||||
<Link
|
||||
prefetch={false}
|
||||
href={`/tasks/${taskId}`}
|
||||
className="inline-flex items-center gap-1 text-xs text-primary hover:underline"
|
||||
>
|
||||
<ListTodo className="h-3 w-3" />
|
||||
View task
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,14 +6,121 @@ import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { getAgentDisplayName } from "@/lib/agent-utils";
|
||||
import type { AdminConversationSummary } from "@/lib/api/a2a";
|
||||
import { usePulseFlash } from "@/hooks/use-pulse-flash";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { ListTodo, MessagesSquare } from "lucide-react";
|
||||
import { PairAvatar } from "./a2a-pair-card";
|
||||
import { PAIR_PULSE_FADE_MS, pairKey } from "./a2a-switchboard-utils";
|
||||
|
||||
interface A2AConversationListProps {
|
||||
conversations: AdminConversationSummary[];
|
||||
selectedId: string | null;
|
||||
onSelect: (id: string) => void;
|
||||
isLoading: boolean;
|
||||
/** pairKey(agent_a, agent_b) -> epoch ms of the latest matching frame —
|
||||
* the same map the switchboard uses, so a row flashes on the same live
|
||||
* pulse as its pair's card. */
|
||||
pulses: Record<string, number>;
|
||||
}
|
||||
|
||||
interface ConversationRowProps {
|
||||
conversation: AdminConversationSummary;
|
||||
isSelected: boolean;
|
||||
onSelect: (id: string) => void;
|
||||
pulsedAt: number | null;
|
||||
}
|
||||
|
||||
function ConversationRow({
|
||||
conversation,
|
||||
isSelected,
|
||||
onSelect,
|
||||
pulsedAt,
|
||||
}: ConversationRowProps) {
|
||||
const isPulsing = usePulseFlash(pulsedAt);
|
||||
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
data-testid="conversation-row"
|
||||
data-pulsing={isPulsing}
|
||||
onClick={() => onSelect(conversation.id)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onSelect(conversation.id);
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"block w-full cursor-pointer p-3 rounded-lg border",
|
||||
"transition-[background-color,box-shadow] ease-out",
|
||||
isSelected ? "border-primary" : "border-border",
|
||||
isPulsing
|
||||
? "bg-emerald-500/15 shadow-[0_0_0_1px_rgba(16,185,129,0.6)]"
|
||||
: isSelected
|
||||
? "bg-primary/10"
|
||||
: "bg-card hover:bg-muted/50 hover:border-primary/50",
|
||||
)}
|
||||
style={{ transitionDuration: `${PAIR_PULSE_FADE_MS}ms` }}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex items-start gap-2 min-w-0 flex-1">
|
||||
<div className="flex -space-x-2 shrink-0 pt-0.5">
|
||||
<PairAvatar slug={conversation.agent_a} />
|
||||
<PairAvatar slug={conversation.agent_b} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-medium text-sm truncate">
|
||||
{getAgentDisplayName(conversation.agent_a)}
|
||||
{" ↔ "}
|
||||
{getAgentDisplayName(conversation.agent_b)}
|
||||
</div>
|
||||
{conversation.topic && (
|
||||
<div className="text-xs text-muted-foreground truncate mt-0.5">
|
||||
{conversation.topic}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
{formatDistanceToNow(
|
||||
new Date(
|
||||
conversation.last_message_at ?? conversation.created_at,
|
||||
),
|
||||
)}{" "}
|
||||
ago
|
||||
</div>
|
||||
{conversation.last_message_preview && (
|
||||
<p className="text-xs text-muted-foreground truncate mt-1">
|
||||
{conversation.last_message_preview}
|
||||
</p>
|
||||
)}
|
||||
{conversation.task_id && (
|
||||
<Link
|
||||
prefetch={false}
|
||||
href={`/tasks/${conversation.task_id}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="inline-flex items-center gap-1 text-xs text-primary hover:underline mt-1"
|
||||
>
|
||||
<ListTodo className="h-3 w-3" />
|
||||
Task {conversation.task_id.slice(0, 8)}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-1 shrink-0">
|
||||
<Badge
|
||||
variant={conversation.status === "active" ? "default" : "secondary"}
|
||||
className="text-xs"
|
||||
>
|
||||
{conversation.status}
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{conversation.message_count} msgs
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function A2AConversationList({
|
||||
@@ -21,6 +128,7 @@ export function A2AConversationList({
|
||||
selectedId,
|
||||
onSelect,
|
||||
isLoading,
|
||||
pulses,
|
||||
}: A2AConversationListProps) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -47,76 +155,16 @@ export function A2AConversationList({
|
||||
<ScrollArea className="h-full">
|
||||
<div className="p-2 space-y-2">
|
||||
{conversations.map((conversation) => (
|
||||
<div
|
||||
<ConversationRow
|
||||
key={conversation.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => onSelect(conversation.id)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onSelect(conversation.id);
|
||||
}
|
||||
}}
|
||||
className={
|
||||
"block w-full cursor-pointer p-3 rounded-lg border transition-all " +
|
||||
(selectedId === conversation.id
|
||||
? "bg-primary/10 border-primary"
|
||||
: "bg-card hover:bg-muted/50 hover:border-primary/50")
|
||||
conversation={conversation}
|
||||
isSelected={selectedId === conversation.id}
|
||||
onSelect={onSelect}
|
||||
pulsedAt={
|
||||
pulses[pairKey(conversation.agent_a, conversation.agent_b)] ??
|
||||
null
|
||||
}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-medium text-sm truncate">
|
||||
{getAgentDisplayName(conversation.agent_a)}
|
||||
{" ↔ "}
|
||||
{getAgentDisplayName(conversation.agent_b)}
|
||||
</div>
|
||||
{conversation.topic && (
|
||||
<div className="text-xs text-muted-foreground truncate mt-0.5">
|
||||
{conversation.topic}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
{formatDistanceToNow(
|
||||
new Date(
|
||||
conversation.last_message_at ?? conversation.created_at,
|
||||
),
|
||||
)}{" "}
|
||||
ago
|
||||
</div>
|
||||
{conversation.last_message_preview && (
|
||||
<p className="text-xs text-muted-foreground truncate mt-1">
|
||||
{conversation.last_message_preview}
|
||||
</p>
|
||||
)}
|
||||
{conversation.task_id && (
|
||||
<Link
|
||||
prefetch={false}
|
||||
href={`/tasks/${conversation.task_id}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="inline-flex items-center gap-1 text-xs text-primary hover:underline mt-1"
|
||||
>
|
||||
<ListTodo className="h-3 w-3" />
|
||||
Task {conversation.task_id.slice(0, 8)}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-1 shrink-0">
|
||||
<Badge
|
||||
variant={
|
||||
conversation.status === "active" ? "default" : "secondary"
|
||||
}
|
||||
className="text-xs"
|
||||
>
|
||||
{conversation.status}
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{conversation.message_count} msgs
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
"use client";
|
||||
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { SlidersHorizontal, X } from "lucide-react";
|
||||
import { getAgentDisplayName } from "@/lib/agent-utils";
|
||||
import {
|
||||
EMPTY_A2A_FILTERS,
|
||||
activeA2AFilterCount,
|
||||
type A2AConversationStatus,
|
||||
type A2AFilters,
|
||||
} from "./a2a-filter-utils";
|
||||
|
||||
const STATUS_OPTIONS: { value: A2AConversationStatus; label: string }[] = [
|
||||
{ value: "active", label: "Active" },
|
||||
{ value: "archived", label: "Archived" },
|
||||
];
|
||||
|
||||
interface A2AFilterBarProps {
|
||||
filters: A2AFilters;
|
||||
onFiltersChange: (filters: A2AFilters) => void;
|
||||
/** Distinct agent slugs to list as checkboxes (already deduped+sorted —
|
||||
* see `distinctA2AAgents`). */
|
||||
agentOptions: string[];
|
||||
/** Task/Status/Date only narrow the List view — Switchboard pairs have no
|
||||
* conversation to filter those dimensions on (design doc §1). */
|
||||
view: "switchboard" | "list";
|
||||
}
|
||||
|
||||
interface FilterChip {
|
||||
key: string;
|
||||
label: string;
|
||||
onRemove: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Popover-triggered filter panel above the switchboard/list content: Agent
|
||||
* (multi-select), Task (id fragment + "No linked task"), Status (toggle
|
||||
* buttons) and a date range — plus the active-filter chip row and
|
||||
* Clear-all. See docs/ux_ui/design/conversations-filter-control.md.
|
||||
*/
|
||||
export function A2AFilterBar({
|
||||
filters,
|
||||
onFiltersChange,
|
||||
agentOptions,
|
||||
view,
|
||||
}: A2AFilterBarProps) {
|
||||
const toggleAgent = (agent: string) => {
|
||||
onFiltersChange({
|
||||
...filters,
|
||||
agents: filters.agents.includes(agent)
|
||||
? filters.agents.filter((a) => a !== agent)
|
||||
: [...filters.agents, agent],
|
||||
});
|
||||
};
|
||||
|
||||
const toggleStatus = (status: A2AConversationStatus) => {
|
||||
onFiltersChange({
|
||||
...filters,
|
||||
statuses: filters.statuses.includes(status)
|
||||
? filters.statuses.filter((s) => s !== status)
|
||||
: [...filters.statuses, status],
|
||||
});
|
||||
};
|
||||
|
||||
const clearAll = () => onFiltersChange(EMPTY_A2A_FILTERS);
|
||||
|
||||
const chips: FilterChip[] = [
|
||||
...filters.agents.map((agent) => ({
|
||||
key: `agent-${agent}`,
|
||||
label: getAgentDisplayName(agent),
|
||||
onRemove: () => toggleAgent(agent),
|
||||
})),
|
||||
...(filters.taskIdFragment
|
||||
? [
|
||||
{
|
||||
key: "task-fragment",
|
||||
label: `Task: ${filters.taskIdFragment}`,
|
||||
onRemove: () => onFiltersChange({ ...filters, taskIdFragment: "" }),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(filters.noLinkedTask
|
||||
? [
|
||||
{
|
||||
key: "no-linked-task",
|
||||
label: "No linked task",
|
||||
onRemove: () =>
|
||||
onFiltersChange({ ...filters, noLinkedTask: false }),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...filters.statuses.map((status) => ({
|
||||
key: `status-${status}`,
|
||||
label: STATUS_OPTIONS.find((o) => o.value === status)?.label ?? status,
|
||||
onRemove: () => toggleStatus(status),
|
||||
})),
|
||||
...(filters.dateFrom
|
||||
? [
|
||||
{
|
||||
key: "date-from",
|
||||
label: `From ${filters.dateFrom}`,
|
||||
onRemove: () => onFiltersChange({ ...filters, dateFrom: "" }),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(filters.dateTo
|
||||
? [
|
||||
{
|
||||
key: "date-to",
|
||||
label: `To ${filters.dateTo}`,
|
||||
onRemove: () => onFiltersChange({ ...filters, dateTo: "" }),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
const count = activeA2AFilterCount(filters);
|
||||
|
||||
return (
|
||||
<div className="mb-2 shrink-0">
|
||||
<div className="flex items-center justify-end">
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 gap-1 px-2 text-xs"
|
||||
>
|
||||
<SlidersHorizontal className="h-3.5 w-3.5" />
|
||||
{count > 0 ? `Filters · ${count}` : "Filters"}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="end"
|
||||
className="w-72 max-h-[70vh] space-y-3 overflow-y-auto"
|
||||
>
|
||||
{view === "switchboard" && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Task, Status, and Date filters apply to the Conversation List
|
||||
view.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<div className="mb-1 flex items-center justify-between">
|
||||
<span className="text-sm font-medium">Agent</span>
|
||||
{filters.agents.length > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs"
|
||||
onClick={() => onFiltersChange({ ...filters, agents: [] })}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="max-h-40 space-y-1 overflow-y-auto">
|
||||
{agentOptions.map((agent) => (
|
||||
<label
|
||||
key={agent}
|
||||
className="flex cursor-pointer items-center gap-2 rounded px-1 py-1 hover:bg-muted"
|
||||
>
|
||||
<Checkbox
|
||||
checked={filters.agents.includes(agent)}
|
||||
onCheckedChange={() => toggleAgent(agent)}
|
||||
/>
|
||||
<span className="text-sm">
|
||||
{getAgentDisplayName(agent)}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-2">
|
||||
<span className="text-sm font-medium">Task</span>
|
||||
<Input
|
||||
value={filters.taskIdFragment}
|
||||
onChange={(e) =>
|
||||
onFiltersChange({
|
||||
...filters,
|
||||
taskIdFragment: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="Task id fragment..."
|
||||
className="mt-1 h-7 text-xs"
|
||||
aria-label="Task id fragment"
|
||||
/>
|
||||
<label className="mt-2 flex cursor-pointer items-center gap-2">
|
||||
<Checkbox
|
||||
checked={filters.noLinkedTask}
|
||||
onCheckedChange={(checked) =>
|
||||
onFiltersChange({
|
||||
...filters,
|
||||
noLinkedTask: checked === true,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span className="text-sm">No linked task</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-2">
|
||||
<span className="text-sm font-medium">Status</span>
|
||||
<div className="mt-1 flex items-center gap-1">
|
||||
{STATUS_OPTIONS.map((opt) => (
|
||||
<Button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
variant={
|
||||
filters.statuses.includes(opt.value)
|
||||
? "secondary"
|
||||
: "outline"
|
||||
}
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs"
|
||||
aria-pressed={filters.statuses.includes(opt.value)}
|
||||
onClick={() => toggleStatus(opt.value)}
|
||||
>
|
||||
{opt.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-2">
|
||||
<span className="text-sm font-medium">Date range</span>
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<Input
|
||||
type="date"
|
||||
value={filters.dateFrom}
|
||||
onChange={(e) =>
|
||||
onFiltersChange({ ...filters, dateFrom: e.target.value })
|
||||
}
|
||||
className="h-7 text-xs"
|
||||
aria-label="From date"
|
||||
/>
|
||||
<Input
|
||||
type="date"
|
||||
value={filters.dateTo}
|
||||
onChange={(e) =>
|
||||
onFiltersChange({ ...filters, dateTo: e.target.value })
|
||||
}
|
||||
className="h-7 text-xs"
|
||||
aria-label="To date"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{count > 0 && (
|
||||
<div className="flex justify-end border-t pt-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs"
|
||||
onClick={clearAll}
|
||||
>
|
||||
Clear all
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
{chips.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||
{chips.map((chip) => (
|
||||
<Badge key={chip.key} variant="secondary" className="gap-1">
|
||||
{chip.label}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Remove ${chip.label} filter`}
|
||||
onClick={chip.onRemove}
|
||||
>
|
||||
<X className="h-3 w-3 cursor-pointer hover:text-destructive" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs"
|
||||
onClick={clearAll}
|
||||
>
|
||||
Clear all
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* Pure filter helpers for the A2A page's filter control — shared by both the
|
||||
* switchboard (pairs) and the classic list (conversations) so the same
|
||||
* filter state narrows both views (per the per-view rules in
|
||||
* docs/ux_ui/design/conversations-filter-control.md §1).
|
||||
*
|
||||
* All four dimensions filter client-side over the already-fetched page
|
||||
* (there are no backend query params for them yet — see the design doc's
|
||||
* "Future work" note).
|
||||
*/
|
||||
|
||||
import type { AdminConversationSummary, AdminPairSummary } from "@/lib/api/a2a";
|
||||
|
||||
/** The two known `AdminConversationSummary.status` values. */
|
||||
export type A2AConversationStatus = "active" | "archived";
|
||||
|
||||
export interface A2AFilters {
|
||||
/** Selected agent slugs — a conversation/pair matches if either
|
||||
* participant is selected (empty = no agent filter). */
|
||||
agents: string[];
|
||||
/** Free-text fragment matched against `task_id` (case-insensitive). */
|
||||
taskIdFragment: string;
|
||||
/** When true, also match conversations with `task_id === null`. */
|
||||
noLinkedTask: boolean;
|
||||
/** Selected statuses (empty = no status filter). List-view only. */
|
||||
statuses: A2AConversationStatus[];
|
||||
/** Inclusive lower bound, `YYYY-MM-DD` (native `<input type="date">`
|
||||
* value) or `""` for unset. List-view only. */
|
||||
dateFrom: string;
|
||||
/** Inclusive upper bound, `YYYY-MM-DD` or `""` for unset. List-view
|
||||
* only. */
|
||||
dateTo: string;
|
||||
}
|
||||
|
||||
export const EMPTY_A2A_FILTERS: A2AFilters = {
|
||||
agents: [],
|
||||
taskIdFragment: "",
|
||||
noLinkedTask: false,
|
||||
statuses: [],
|
||||
dateFrom: "",
|
||||
dateTo: "",
|
||||
};
|
||||
|
||||
/** Total count of active filter values, one per chip — drives the
|
||||
* trigger's `Filters · N` badge. */
|
||||
export function activeA2AFilterCount(filters: A2AFilters): number {
|
||||
return (
|
||||
filters.agents.length +
|
||||
(filters.taskIdFragment.trim() ? 1 : 0) +
|
||||
(filters.noLinkedTask ? 1 : 0) +
|
||||
filters.statuses.length +
|
||||
(filters.dateFrom ? 1 : 0) +
|
||||
(filters.dateTo ? 1 : 0)
|
||||
);
|
||||
}
|
||||
|
||||
function matchesAgent(
|
||||
agentA: string,
|
||||
agentB: string,
|
||||
agents: ReadonlyArray<string>,
|
||||
): boolean {
|
||||
if (agents.length === 0) return true;
|
||||
return agents.includes(agentA) || agents.includes(agentB);
|
||||
}
|
||||
|
||||
function matchesTask(
|
||||
taskId: string | null,
|
||||
fragment: string,
|
||||
noLinkedTask: boolean,
|
||||
): boolean {
|
||||
const frag = fragment.trim().toLowerCase();
|
||||
if (!frag && !noLinkedTask) return true;
|
||||
const fragmentMatch = frag
|
||||
? !!taskId && taskId.toLowerCase().includes(frag)
|
||||
: false;
|
||||
const noLinkedMatch = noLinkedTask ? taskId === null : false;
|
||||
return fragmentMatch || noLinkedMatch;
|
||||
}
|
||||
|
||||
function matchesStatus(
|
||||
status: string,
|
||||
statuses: ReadonlyArray<A2AConversationStatus>,
|
||||
): boolean {
|
||||
if (statuses.length === 0) return true;
|
||||
return statuses.includes(status as A2AConversationStatus);
|
||||
}
|
||||
|
||||
/** Day-granularity local-timezone `YYYY-MM-DD`, comparable against a native
|
||||
* `<input type="date">` value. */
|
||||
function localDateOnly(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(d.getDate()).padStart(2, "0");
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
function matchesDateRange(
|
||||
timestamp: string | null,
|
||||
dateFrom: string,
|
||||
dateTo: string,
|
||||
): boolean {
|
||||
if (!dateFrom && !dateTo) return true;
|
||||
if (!timestamp) return false;
|
||||
const day = localDateOnly(timestamp);
|
||||
if (dateFrom && day < dateFrom) return false;
|
||||
if (dateTo && day > dateTo) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Narrow conversations by all four dimensions — the classic List view. */
|
||||
export function filterConversations(
|
||||
conversations: ReadonlyArray<AdminConversationSummary>,
|
||||
filters: A2AFilters,
|
||||
): AdminConversationSummary[] {
|
||||
return conversations.filter(
|
||||
(conversation) =>
|
||||
matchesAgent(
|
||||
conversation.agent_a,
|
||||
conversation.agent_b,
|
||||
filters.agents,
|
||||
) &&
|
||||
matchesTask(
|
||||
conversation.task_id,
|
||||
filters.taskIdFragment,
|
||||
filters.noLinkedTask,
|
||||
) &&
|
||||
matchesStatus(conversation.status, filters.statuses) &&
|
||||
matchesDateRange(
|
||||
conversation.last_message_at ?? conversation.created_at,
|
||||
filters.dateFrom,
|
||||
filters.dateTo,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** Narrow switchboard pairs — Agent only (design doc §1 "Per-view
|
||||
* applicability"): a pair with no conversation has no task/status/date to
|
||||
* filter on. */
|
||||
export function filterPairs(
|
||||
pairs: ReadonlyArray<AdminPairSummary>,
|
||||
filters: A2AFilters,
|
||||
): AdminPairSummary[] {
|
||||
return pairs.filter((pair) =>
|
||||
matchesAgent(pair.agent_a, pair.agent_b, filters.agents),
|
||||
);
|
||||
}
|
||||
|
||||
/** Distinct agent slugs present across the currently loaded pairs +
|
||||
* conversations, deduplicated and sorted — the Agent checkbox list's
|
||||
* option set (design doc §1, dimension 1). */
|
||||
export function distinctA2AAgents(
|
||||
conversations: ReadonlyArray<AdminConversationSummary>,
|
||||
pairs: ReadonlyArray<AdminPairSummary>,
|
||||
): string[] {
|
||||
const slugs = new Set<string>();
|
||||
for (const pair of pairs) {
|
||||
slugs.add(pair.agent_a);
|
||||
slugs.add(pair.agent_b);
|
||||
}
|
||||
for (const conversation of conversations) {
|
||||
slugs.add(conversation.agent_a);
|
||||
slugs.add(conversation.agent_b);
|
||||
}
|
||||
return Array.from(slugs).sort();
|
||||
}
|
||||
@@ -1,10 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { getAgentDisplayName, getAgentInitials } from "@/lib/agent-utils";
|
||||
import {
|
||||
getAgentDisplayName,
|
||||
getAgentInitials,
|
||||
getAgentTeamColor,
|
||||
TEAM_COLOR_CLASSES,
|
||||
} from "@/lib/agent-utils";
|
||||
import type { AdminPairSummary } from "@/lib/api/a2a";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { usePulseFlash } from "@/hooks/use-pulse-flash";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { PAIR_PULSE_FADE_MS } from "./a2a-switchboard-utils";
|
||||
|
||||
@@ -17,10 +22,16 @@ interface A2APairCardProps {
|
||||
onOpen: () => void;
|
||||
}
|
||||
|
||||
function PairAvatar({ slug }: { slug: string }) {
|
||||
/** One agent's avatar (initials in a circle) — shared with
|
||||
* A2AConversationList so a pair/conversation's two participants render
|
||||
* identically across the switchboard and the classic list. */
|
||||
export function PairAvatar({ slug }: { slug: string }) {
|
||||
return (
|
||||
<div
|
||||
className="h-7 w-7 rounded-full bg-primary/10 border flex items-center justify-center shrink-0"
|
||||
className={cn(
|
||||
"h-7 w-7 rounded-full border flex items-center justify-center shrink-0",
|
||||
TEAM_COLOR_CLASSES[getAgentTeamColor(slug)],
|
||||
)}
|
||||
title={getAgentDisplayName(slug)}
|
||||
>
|
||||
<span className="text-[9px] font-bold tracking-tight">
|
||||
@@ -44,31 +55,7 @@ export function A2APairCard({
|
||||
onOpen,
|
||||
}: A2APairCardProps) {
|
||||
const hasHistory = pair.conversation_id !== null;
|
||||
const [isPulsing, setIsPulsing] = useState(false);
|
||||
|
||||
// Render-phase derivation, not an Effect (react.dev/learn/you-might-not-
|
||||
// need-an-effect#adjusting-some-state-when-a-prop-changes): flash hot in
|
||||
// the very same render that receives a new pulsedAt, comparing against the
|
||||
// last value we've seen. No cascading extra render from an Effect body.
|
||||
// Seeded to null (not the initial pulsedAt) so a card that *mounts*
|
||||
// already carrying a live pulse — e.g. switching into switchboard view
|
||||
// right after a frame arrived — still flashes hot instead of looking cold.
|
||||
const [lastSeenPulse, setLastSeenPulse] = useState<number | null>(null);
|
||||
if (pulsedAt !== lastSeenPulse) {
|
||||
setLastSeenPulse(pulsedAt);
|
||||
if (pulsedAt !== null) setIsPulsing(true);
|
||||
}
|
||||
|
||||
// Flip back on the next paint frame — the long CSS transition-duration
|
||||
// below then animates the decay from "hot" to baseline over
|
||||
// PAIR_PULSE_FADE_MS. The setState here is inside the (async) rAF
|
||||
// callback, not the Effect body itself, so it's the intended "subscribe to
|
||||
// an external clock" use of an Effect.
|
||||
useEffect(() => {
|
||||
if (!isPulsing) return;
|
||||
const raf = requestAnimationFrame(() => setIsPulsing(false));
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [isPulsing]);
|
||||
const isPulsing = usePulseFlash(pulsedAt);
|
||||
|
||||
return (
|
||||
<button
|
||||
|
||||
@@ -1,28 +1,111 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Markdown } from "@/components/ui/markdown";
|
||||
import { getAgentDisplayName, getAgentInitials } from "@/lib/agent-utils";
|
||||
import {
|
||||
getAgentDisplayName,
|
||||
getAgentInitials,
|
||||
getAgentTeamColor,
|
||||
TEAM_COLOR_CLASSES,
|
||||
} from "@/lib/agent-utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { A2AChatMessage } from "@/lib/api/a2a";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { MessagesSquare } from "lucide-react";
|
||||
import { AlertTriangle, MessagesSquare } from "lucide-react";
|
||||
|
||||
interface A2ATranscriptProps {
|
||||
messages: A2AChatMessage[];
|
||||
isLoading: boolean;
|
||||
/** False when no conversation/pair is selected at all — distinguishes
|
||||
* "nothing to show yet" from "this conversation genuinely has no
|
||||
* messages" (design doc §5). Defaults true (existing callers). */
|
||||
hasSelection?: boolean;
|
||||
/** True when the messages fetch itself failed — a scoped retry, not the
|
||||
* page-level OfflineState (design doc §5). */
|
||||
error?: boolean;
|
||||
onRetry?: () => void;
|
||||
}
|
||||
|
||||
export function A2ATranscript({ messages, isLoading }: A2ATranscriptProps) {
|
||||
/** How close to the bottom (px) still counts as "at the bottom" for the
|
||||
* auto-scroll / new-messages-pill decision. */
|
||||
const BOTTOM_THRESHOLD_PX = 48;
|
||||
|
||||
function EmptyState({
|
||||
icon: Icon,
|
||||
message,
|
||||
}: {
|
||||
icon: typeof MessagesSquare;
|
||||
message: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center text-muted-foreground">
|
||||
<div className="text-center p-4">
|
||||
<Icon className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
||||
<p className="text-sm">{message}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function A2ATranscript({
|
||||
messages,
|
||||
isLoading,
|
||||
hasSelection = true,
|
||||
error = false,
|
||||
onRetry,
|
||||
}: A2ATranscriptProps) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const hasScrolledRef = useRef(false);
|
||||
const [isAtBottom, setIsAtBottom] = useState(true);
|
||||
const [seenIds, setSeenIds] = useState<Set<string> | null>(null);
|
||||
const [newRowIds, setNewRowIds] = useState<ReadonlySet<string>>(new Set());
|
||||
const [showJumpPill, setShowJumpPill] = useState(false);
|
||||
const [pillEntering, setPillEntering] = useState(false);
|
||||
|
||||
// Chronological (oldest first) regardless of payload ordering.
|
||||
const sorted = [...messages].sort(
|
||||
(a, b) =>
|
||||
new Date(a.created_at).getTime() - new Date(b.created_at).getTime(),
|
||||
);
|
||||
const currentIds = sorted.map((m) => m.id);
|
||||
|
||||
// Render-phase derivation (same idiom as A2APairCard's usePulseFlash — state,
|
||||
// not a ref, compared against the current props): the first time a batch of
|
||||
// ids appears it seeds "seen" without flagging anything new — messages
|
||||
// present at initial load must render settled, never animate in. A later id
|
||||
// absent from "seen" is a genuine arrival.
|
||||
if (seenIds === null) {
|
||||
setSeenIds(new Set(currentIds));
|
||||
} else {
|
||||
const freshIds = currentIds.filter((id) => !seenIds.has(id));
|
||||
if (freshIds.length > 0) {
|
||||
setSeenIds(new Set([...seenIds, ...freshIds]));
|
||||
if (isAtBottom) {
|
||||
setNewRowIds((prev) => new Set([...prev, ...freshIds]));
|
||||
} else {
|
||||
// Scrolled up: the new row is off-screen — surface the "New
|
||||
// messages" pill instead of an invisible entrance transition.
|
||||
setShowJumpPill(true);
|
||||
setPillEntering(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Settle the entrance transition one paint frame after new rows appear.
|
||||
useEffect(() => {
|
||||
if (newRowIds.size === 0) return;
|
||||
const raf = requestAnimationFrame(() => setNewRowIds(new Set()));
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [newRowIds]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pillEntering) return;
|
||||
const raf = requestAnimationFrame(() => setPillEntering(false));
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [pillEntering]);
|
||||
|
||||
// Auto-scroll to bottom only once on initial load.
|
||||
useEffect(() => {
|
||||
@@ -32,6 +115,23 @@ export function A2ATranscript({ messages, isLoading }: A2ATranscriptProps) {
|
||||
}
|
||||
}, [sorted.length]);
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
const atBottom =
|
||||
el.scrollHeight - el.scrollTop - el.clientHeight < BOTTOM_THRESHOLD_PX;
|
||||
setIsAtBottom(atBottom);
|
||||
if (atBottom) setShowJumpPill(false);
|
||||
}, []);
|
||||
|
||||
const scrollToBottom = useCallback(() => {
|
||||
scrollRef.current?.scrollTo({
|
||||
top: scrollRef.current.scrollHeight,
|
||||
behavior: "smooth",
|
||||
});
|
||||
setShowJumpPill(false);
|
||||
}, []);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="p-4 space-y-4">
|
||||
@@ -48,51 +148,117 @@ export function A2ATranscript({ messages, isLoading }: A2ATranscriptProps) {
|
||||
);
|
||||
}
|
||||
|
||||
if (sorted.length === 0) {
|
||||
if (error) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center text-muted-foreground">
|
||||
<div className="text-center p-4">
|
||||
<MessagesSquare className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
||||
<p className="text-sm">No messages in this conversation yet</p>
|
||||
<AlertTriangle className="h-8 w-8 mx-auto mb-2 opacity-50 text-destructive" />
|
||||
<p className="text-sm mb-3">Couldn't load this conversation</p>
|
||||
{onRetry && (
|
||||
<Button variant="outline" size="sm" onClick={onRetry}>
|
||||
Retry
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!hasSelection) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={MessagesSquare}
|
||||
message="Select a conversation to view messages"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (sorted.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={MessagesSquare}
|
||||
message="No messages in this conversation yet"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={scrollRef} className="h-full overflow-y-auto p-4">
|
||||
<div className="space-y-3">
|
||||
{sorted.map((message) => (
|
||||
<div
|
||||
key={message.id}
|
||||
className="flex gap-3 p-3 rounded-lg border bg-card hover:bg-muted/30 transition-colors"
|
||||
>
|
||||
<div className="h-9 w-10 rounded-lg bg-primary/10 flex items-center justify-center shrink-0 border">
|
||||
<span className="text-[10px] font-bold tracking-tight">
|
||||
{getAgentInitials(message.from_agent)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<span className="font-semibold text-sm">
|
||||
{getAgentDisplayName(message.from_agent)}
|
||||
</span>
|
||||
{message.message_kind && (
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
{message.message_kind}
|
||||
</Badge>
|
||||
<div className="relative h-full">
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onScroll={handleScroll}
|
||||
className="h-full overflow-y-auto p-4"
|
||||
>
|
||||
<div className="space-y-3">
|
||||
{sorted.map((message) => {
|
||||
const isNew = newRowIds.has(message.id);
|
||||
const teamColor = getAgentTeamColor(message.from_agent);
|
||||
return (
|
||||
<div
|
||||
key={message.id}
|
||||
data-testid="transcript-row"
|
||||
data-new={isNew}
|
||||
className={cn(
|
||||
"flex gap-3 p-3 rounded-lg border hover:bg-muted/30",
|
||||
// ponytail: one shared 200ms transition covers
|
||||
// opacity/transform/background so the reduced-motion
|
||||
// background flash rides the same timing as the
|
||||
// full-motion fade-in instead of a second bespoke duration.
|
||||
"transition-[opacity,transform,background-color] duration-200 ease-out",
|
||||
"motion-reduce:transition-colors motion-reduce:translate-y-0",
|
||||
isNew
|
||||
? "opacity-0 translate-y-1 bg-muted/50 motion-reduce:opacity-100"
|
||||
: "opacity-100 translate-y-0 bg-card",
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground ml-auto">
|
||||
{formatDistanceToNow(new Date(message.created_at))} ago
|
||||
</span>
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"h-9 w-10 rounded-lg flex items-center justify-center shrink-0 border",
|
||||
TEAM_COLOR_CLASSES[teamColor],
|
||||
)}
|
||||
>
|
||||
<span className="text-[10px] font-bold tracking-tight">
|
||||
{getAgentInitials(message.from_agent)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<span className="font-semibold text-sm">
|
||||
{getAgentDisplayName(message.from_agent)}
|
||||
</span>
|
||||
{message.message_kind && (
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
{message.message_kind}
|
||||
</Badge>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground ml-auto">
|
||||
{formatDistanceToNow(new Date(message.created_at))} ago
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm prose prose-sm dark:prose-invert max-w-none">
|
||||
<Markdown>{message.content}</Markdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm prose prose-sm dark:prose-invert max-w-none">
|
||||
<Markdown>{message.content}</Markdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{showJumpPill && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={scrollToBottom}
|
||||
className={cn(
|
||||
"absolute bottom-3 left-1/2 rounded-full bg-primary text-primary-foreground text-xs px-3 py-1 shadow-md",
|
||||
"transition-[opacity,transform] duration-200 ease-out motion-reduce:transition-none",
|
||||
pillEntering
|
||||
? "opacity-0 translate-x-[-50%] translate-y-1 motion-reduce:opacity-100 motion-reduce:translate-y-0"
|
||||
: "opacity-100 translate-x-[-50%] translate-y-0",
|
||||
)}
|
||||
>
|
||||
New messages ↓
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
*/
|
||||
|
||||
import type { A2AChatMessage } from "@/lib/api/a2a";
|
||||
import type { ConnectionState } from "@/lib/websocket/connection";
|
||||
|
||||
/** The human CEO's fixed slug — never a valid reply target (the CEO composes
|
||||
* as itself, so it can't be its own recipient). */
|
||||
@@ -47,3 +48,32 @@ export function pickDefaultRecipient(
|
||||
if (lastSender === agentA || lastSender === agentB) return lastSender;
|
||||
return agentA;
|
||||
}
|
||||
|
||||
/** Label for the pane-header connection badge (design doc §3). */
|
||||
export function connectionStateLabel(state: ConnectionState): string {
|
||||
switch (state) {
|
||||
case "connected":
|
||||
return "Live";
|
||||
case "connecting":
|
||||
return "Connecting…";
|
||||
case "reconnecting":
|
||||
return "Reconnecting…";
|
||||
case "disconnected":
|
||||
return "Offline";
|
||||
}
|
||||
}
|
||||
|
||||
/** Dot color for the pane-header connection badge — `connected` is static
|
||||
* (no pulse); `connecting`/`reconnecting` share the amber pulsing family,
|
||||
* guarded against `prefers-reduced-motion` (design doc §3). */
|
||||
export function connectionDotClasses(state: ConnectionState): string {
|
||||
switch (state) {
|
||||
case "connected":
|
||||
return "bg-emerald-500";
|
||||
case "connecting":
|
||||
case "reconnecting":
|
||||
return "bg-amber-500 animate-pulse motion-reduce:animate-none";
|
||||
case "disconnected":
|
||||
return "bg-muted-foreground/40";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,9 +91,9 @@ describe("pipelineStageLabel", () => {
|
||||
expect(
|
||||
pipelineStageLabel({ kind: "rendering", attempt: 2, maxAttempts: 5 }),
|
||||
).toBe("Rendering (attempt 2/5)");
|
||||
expect(
|
||||
pipelineStageLabel({ kind: "render_failed", reason: "boom" }),
|
||||
).toBe("Render failed: boom");
|
||||
expect(pipelineStageLabel({ kind: "render_failed", reason: "boom" })).toBe(
|
||||
"Render failed: boom",
|
||||
);
|
||||
expect(pipelineStageLabel({ kind: "render_failed", reason: null })).toBe(
|
||||
"Render failed",
|
||||
);
|
||||
|
||||
@@ -150,10 +150,12 @@ export function SocialHistorySection({ className }: { className?: string }) {
|
||||
const isLoading = open && (xLoading || videoLoading);
|
||||
const rows: UnifiedRow[] = [
|
||||
...(xHistory ?? []).map((entry): UnifiedRow => ({ kind: "x", entry })),
|
||||
...(videoHistory ?? []).map((entry): UnifiedRow => ({
|
||||
kind: "video",
|
||||
entry,
|
||||
})),
|
||||
...(videoHistory ?? []).map(
|
||||
(entry): UnifiedRow => ({
|
||||
kind: "video",
|
||||
entry,
|
||||
}),
|
||||
),
|
||||
].sort(
|
||||
(a, b) =>
|
||||
new Date(b.entry.acted_at).getTime() -
|
||||
|
||||
@@ -32,7 +32,11 @@ const IN_REVIEW_STATUSES = new Set([
|
||||
export function derivePipelineStage(
|
||||
item: Pick<
|
||||
VideoPipelineItem,
|
||||
"status" | "render_status" | "render_attempts" | "max_attempts" | "render_error"
|
||||
| "status"
|
||||
| "render_status"
|
||||
| "render_attempts"
|
||||
| "max_attempts"
|
||||
| "render_error"
|
||||
>,
|
||||
): PipelineStage {
|
||||
if (item.status === "completed") {
|
||||
@@ -46,7 +50,8 @@ export function derivePipelineStage(
|
||||
maxAttempts: item.max_attempts,
|
||||
};
|
||||
}
|
||||
if (item.status === "awaiting_ceo_approval") return { kind: "awaiting_approval" };
|
||||
if (item.status === "awaiting_ceo_approval")
|
||||
return { kind: "awaiting_approval" };
|
||||
if (IN_REVIEW_STATUSES.has(item.status)) return { kind: "in_review" };
|
||||
return { kind: "authoring" };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
/**
|
||||
* True for one paint frame after `pulsedAt` changes to a non-null value, then
|
||||
* flips back to false — the consumer's own CSS `transition-duration` does the
|
||||
* actual fade-out. Shared between A2APairCard (switchboard) and
|
||||
* A2AConversationList (classic list) so both flash consistently off the same
|
||||
* `pairKey -> epoch ms` pulse map.
|
||||
*
|
||||
* Render-phase derivation (react.dev/learn/you-might-not-need-an-effect
|
||||
* #adjusting-some-state-when-a-prop-changes), not an Effect keyed on
|
||||
* `pulsedAt`: flips hot in the very same render that receives a new
|
||||
* `pulsedAt`, comparing against the last value seen. Seeded to `null` (not
|
||||
* the initial `pulsedAt`) so a component that *mounts* already carrying a
|
||||
* live pulse still flashes hot instead of looking cold.
|
||||
*/
|
||||
export function usePulseFlash(pulsedAt: number | null): boolean {
|
||||
const [isPulsing, setIsPulsing] = useState(false);
|
||||
const [lastSeenPulse, setLastSeenPulse] = useState<number | null>(null);
|
||||
if (pulsedAt !== lastSeenPulse) {
|
||||
setLastSeenPulse(pulsedAt);
|
||||
if (pulsedAt !== null) setIsPulsing(true);
|
||||
}
|
||||
|
||||
// Flip back on the next paint frame — the async rAF callback is the
|
||||
// intended "subscribe to an external clock" use of an Effect.
|
||||
useEffect(() => {
|
||||
if (!isPulsing) return;
|
||||
const raf = requestAnimationFrame(() => setIsPulsing(false));
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [isPulsing]);
|
||||
|
||||
return isPulsing;
|
||||
}
|
||||
@@ -3,8 +3,11 @@ import {
|
||||
resolveToSlug,
|
||||
getAgentDisplayName,
|
||||
getAgentInitials,
|
||||
getAgentTeamColor,
|
||||
isKnownAgent,
|
||||
registerAgentRoster,
|
||||
TEAM_COLOR_CLASSES,
|
||||
type AgentTeamColor,
|
||||
} from "@/lib/agent-utils";
|
||||
|
||||
// Canonical UUIDs from the backend roster (roboco/foundation/identity.py).
|
||||
@@ -87,3 +90,51 @@ describe("agent-utils existing roster (regression guard)", () => {
|
||||
expect(getAgentDisplayName(null)).toBe("Unassigned");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAgentTeamColor (design doc §2 — six buckets)", () => {
|
||||
it.each([
|
||||
["be-dev-1", "backend"],
|
||||
["be-pm", "backend"],
|
||||
["fe-dev-2", "frontend"],
|
||||
["fe-qa", "frontend"],
|
||||
["ux-dev-1", "ux_ui"],
|
||||
["ux-doc", "ux_ui"],
|
||||
["main-pm", "board"],
|
||||
["product-owner", "board"],
|
||||
["head-marketing", "board"],
|
||||
["auditor", "board"],
|
||||
["ceo", "ceo"],
|
||||
["CEO", "ceo"],
|
||||
["intake-1", "system"],
|
||||
["secretary-1", "system"],
|
||||
["pr-reviewer-1", "system"],
|
||||
] satisfies [string, AgentTeamColor][])(
|
||||
"buckets %s as %s",
|
||||
(slug, expected) => {
|
||||
expect(getAgentTeamColor(slug)).toBe(expected);
|
||||
},
|
||||
);
|
||||
|
||||
it("resolves a UUID to its team bucket via the slug map", () => {
|
||||
expect(getAgentTeamColor(BE_DEV_1_UUID)).toBe("backend");
|
||||
});
|
||||
|
||||
it("falls back to system for an unrecognized id, never throwing", () => {
|
||||
expect(getAgentTeamColor("some-unknown-agent")).toBe("system");
|
||||
expect(getAgentTeamColor(null)).toBe("system");
|
||||
});
|
||||
|
||||
it("gives every bucket a class string with no new color families beyond the six", () => {
|
||||
const buckets: AgentTeamColor[] = [
|
||||
"backend",
|
||||
"frontend",
|
||||
"ux_ui",
|
||||
"board",
|
||||
"ceo",
|
||||
"system",
|
||||
];
|
||||
for (const bucket of buckets) {
|
||||
expect(TEAM_COLOR_CLASSES[bucket]).toBeTruthy();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -229,3 +229,59 @@ export function isKnownAgent(agentId: string | null | undefined): boolean {
|
||||
if (!agentId) return false;
|
||||
return liveByKey.has(agentId) || agentId in AGENT_NAMES;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Team-color identity (design doc:
|
||||
// docs/ux_ui/design/02-conversation-first-layout-agent-identity-live-stream.md
|
||||
// §2). Color is scoped to the CELL an agent belongs to, not a per-agent hue —
|
||||
// legible at 22-agent scale and needs no new bucket when a cell grows.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type AgentTeamColor =
|
||||
| "backend"
|
||||
| "frontend"
|
||||
| "ux_ui"
|
||||
| "board"
|
||||
| "ceo"
|
||||
| "system";
|
||||
|
||||
/** Every value here is an existing Tailwind color family already used
|
||||
* elsewhere in the codebase at the same `/15` bg + `/40` border weight
|
||||
* (`a2a-pair-card.tsx`'s pulse treatment) — no new tokens introduced. */
|
||||
export const TEAM_COLOR_CLASSES: Record<AgentTeamColor, string> = {
|
||||
backend: "bg-blue-500/15 border-blue-500/40 text-blue-700 dark:text-blue-400",
|
||||
frontend:
|
||||
"bg-violet-500/15 border-violet-500/40 text-violet-700 dark:text-violet-400",
|
||||
ux_ui:
|
||||
"bg-fuchsia-500/15 border-fuchsia-500/40 text-fuchsia-700 dark:text-fuchsia-400",
|
||||
board:
|
||||
"bg-amber-500/15 border-amber-500/40 text-amber-700 dark:text-amber-400",
|
||||
// The one human gets the app's own accent, not a team bucket.
|
||||
ceo: "bg-primary/15 border-primary/40 text-primary",
|
||||
system:
|
||||
"bg-slate-500/15 border-slate-500/40 text-slate-700 dark:text-slate-400",
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve an agent id (slug or UUID) to its cell color bucket, derived from
|
||||
* the slug prefix. Unknown/unresolved slugs fall back to `system` — a color
|
||||
* layer is a scanning aid, never something that should throw on a stray id.
|
||||
*/
|
||||
export function getAgentTeamColor(
|
||||
agentId: string | null | undefined,
|
||||
): AgentTeamColor {
|
||||
const slug = resolveToSlug(agentId);
|
||||
if (slug === "ceo" || slug === "CEO") return "ceo";
|
||||
if (slug.startsWith("be-")) return "backend";
|
||||
if (slug.startsWith("fe-")) return "frontend";
|
||||
if (slug.startsWith("ux-")) return "ux_ui";
|
||||
if (
|
||||
slug === "main-pm" ||
|
||||
slug === "product-owner" ||
|
||||
slug === "head-marketing" ||
|
||||
slug === "auditor"
|
||||
) {
|
||||
return "board";
|
||||
}
|
||||
return "system";
|
||||
}
|
||||
|
||||
@@ -13,11 +13,16 @@ interface UIState {
|
||||
// Current context
|
||||
currentTeam: Team | null;
|
||||
|
||||
// A2A live view: xl:+ context pane collapse (conversation-first layout
|
||||
// design doc §1) — same persisted-preference idiom as sidebar/theme.
|
||||
a2aContextOpen: boolean;
|
||||
|
||||
// Actions
|
||||
toggleSidebar: () => void;
|
||||
setSidebarCollapsed: (collapsed: boolean) => void;
|
||||
setTheme: (theme: "light" | "dark" | "system") => void;
|
||||
setCurrentTeam: (team: Team | null) => void;
|
||||
toggleA2AContext: () => void;
|
||||
}
|
||||
|
||||
export const useUIStore = create<UIState>()(
|
||||
@@ -27,12 +32,15 @@ export const useUIStore = create<UIState>()(
|
||||
sidebarCollapsed: false,
|
||||
theme: "system",
|
||||
currentTeam: null,
|
||||
a2aContextOpen: true,
|
||||
|
||||
toggleSidebar: () =>
|
||||
set((state) => ({ sidebarOpen: !state.sidebarOpen })),
|
||||
setSidebarCollapsed: (collapsed) => set({ sidebarCollapsed: collapsed }),
|
||||
setTheme: (theme) => set({ theme }),
|
||||
setCurrentTeam: (team) => set({ currentTeam: team }),
|
||||
toggleA2AContext: () =>
|
||||
set((state) => ({ a2aContextOpen: !state.a2aContextOpen })),
|
||||
}),
|
||||
{
|
||||
name: "roboco-ui-storage",
|
||||
@@ -40,6 +48,7 @@ export const useUIStore = create<UIState>()(
|
||||
sidebarCollapsed: state.sidebarCollapsed,
|
||||
theme: state.theme,
|
||||
currentTeam: state.currentTeam,
|
||||
a2aContextOpen: state.a2aContextOpen,
|
||||
}),
|
||||
},
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user