Skip to content

Commit 2799a8e

Browse files
BinLiang2021claude
andcommitted
fix(dashboard): v2.1.2 — card click restored, rail dims after dismiss, CALLBACK verb names module
Fixes 3 issues reported after v2.1.1: 1. 'Dismiss 之后框还是红的' (rail stays red after banner dismiss) Root cause: `health` is server-derived from queue state. Frontend dismiss only hides the banner row; rail color stayed bright red/amber even after user acknowledged. Fix: new hook `useAllBannersDismissed(keys)` subscribes to the same sessionStorage + custom-event stream the banner uses. When every banner on a card has been dismissed, the rail and card-tint drop to opacity-40 (still the same semantic color, just visually quiet). If a new banner appears (count changes → new signature → new key → not-yet-dismissed), the rail un-dims automatically. Exports `bannerKey()` from expandState so banner and card agree on the storage key format. 2. '卡片点不开了,只能看概况' (card body no longer clickable) Root cause: in v2.1.1 I moved the expand toggle onto the `▾ more` text button alone and removed onClick from the card <div> itself. Users instinctively click anywhere on the card and got no response. Fix: restore `onClick={onToggleExpand}` on the card body. All inner interactive regions (banner [×], section headers, session/job items, action buttons) already call `e.stopPropagation()` so clicking a Retry button or expanding a section does not also toggle the whole card. Added role="button", tabIndex, aria-expanded, and Enter/Space keyboard handler for accessibility. The `▾ more` label stays as a visual hint (no longer a button — the whole card is the button now). 3. 'Callback 不知道对象是谁' (CALLBACK verb too generic) Root cause: `humanize_verb` returned a hard-coded 'Resuming after callback' for CALLBACK kind. The route already had `instances` (list of in_progress module_instances with module_class + description) but humanize_verb didn't receive them. Fix: add `instances` param to humanize_verb and produce: - 0 inst → 'Processing callback' - 1 inst → 'Running SocialNetworkModule: syncing entity graph' (module_class + description, truncated at 60 chars) - N inst → 'Running 3 modules (Mod1, Mod2, Mod3)' Same treatment extended to SKILL_STUDY and MATRIX — all three kinds now name the active module instance instead of showing a generic label. Tests: 80 backend pytest pass (27 v2.1 tests after adding 5 new CALLBACK cases). tsc -b exit 0. eslint dashboard files: 0 errors. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent a1f338e commit 2799a8e

File tree

6 files changed

+182
-49
lines changed

6 files changed

+182
-49
lines changed

backend/routes/_dashboard_helpers.py

Lines changed: 46 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -468,20 +468,27 @@ def humanize_verb(
468468
sessions: list,
469469
running_jobs: list,
470470
last_activity_at: str | None,
471+
instances: list | None = None,
471472
) -> str:
472473
"""v2.1: turn kind + context into a human-readable 'verb' line.
473474
475+
v2.1.2: CALLBACK / SKILL_STUDY / MATRIX now use `instances` (in_progress
476+
module_instances) to name the specific module — "Callback" alone doesn't
477+
tell the user what module is running or what it's doing.
478+
474479
Examples:
475-
idle + recent → "Idle · last active 4m ago"
476-
idle + never → "Never run"
477-
CHAT + 3 users → "Serving 3 users"
478-
CHAT + 1 user → "In conversation with Alice"
479-
JOB + 1 → "Running: weekly-report"
480-
JOB + 2 → "Running 2 jobs"
481-
MESSAGE_BUS → "Forwarding bus messages"
482-
A2A → "Called by another agent"
483-
CALLBACK → "Resuming after external callback"
480+
idle + recent → "Idle · last active 4m ago"
481+
CHAT + 1 user → "In conversation with Alice"
482+
CHAT + 3 users → "Serving 3 users"
483+
JOB + 1 → "Running: weekly-report"
484+
JOB + 2 → "Running 2 jobs"
485+
MESSAGE_BUS → "Handling bus message"
486+
A2A → "Called by another agent"
487+
CALLBACK + 1 inst → "Running SocialNetworkModule: syncing entity graph"
488+
CALLBACK + 2 inst → "Running 2 modules (SocialNetworkModule, JobModule)"
489+
CALLBACK + 0 inst → "Processing callback"
484490
"""
491+
instances = instances or []
485492
if kind == "idle":
486493
if not last_activity_at:
487494
return "Never run"
@@ -521,12 +528,36 @@ def humanize_verb(
521528
return "Handling bus message"
522529
if kind == "A2A":
523530
return "Called by another agent"
524-
if kind == "CALLBACK":
525-
return "Resuming after callback"
526-
if kind == "SKILL_STUDY":
527-
return "Learning a skill"
528-
if kind == "MATRIX":
529-
return "Running matrix flow"
531+
532+
# CALLBACK / SKILL_STUDY / MATRIX all surface the module that's running.
533+
# "Callback" by itself is just a trigger category — the user wants to know
534+
# WHICH module instance is active.
535+
if kind in ("CALLBACK", "SKILL_STUDY", "MATRIX"):
536+
if not instances:
537+
fallback = {
538+
"CALLBACK": "Processing callback",
539+
"SKILL_STUDY": "Learning a skill",
540+
"MATRIX": "Running matrix flow",
541+
}
542+
return fallback[kind]
543+
if len(instances) == 1:
544+
inst = instances[0]
545+
module = inst.get("module_class") or "module"
546+
desc = (inst.get("description") or "").strip()
547+
if desc:
548+
short = desc[:60] + "…" if len(desc) > 60 else desc
549+
return f"Running {module}: {short}"
550+
return f"Running {module}"
551+
# Multiple instances — enumerate up to 3 module classes
552+
modules = [i.get("module_class") or "module" for i in instances]
553+
uniq = []
554+
for m in modules:
555+
if m not in uniq:
556+
uniq.append(m)
557+
sample = ", ".join(uniq[:3])
558+
more = "" if len(uniq) <= 3 else f" + {len(uniq) - 3} more"
559+
return f"Running {len(instances)} modules ({sample}{more})"
560+
530561
return f"Running ({kind})"
531562

532563

backend/routes/dashboard.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,9 +168,12 @@ async def agents_status(request: Request, response: Response):
168168
)
169169

170170
# v2.1: verb line (human-readable)
171+
# v2.1.2: pass instances so CALLBACK/SKILL_STUDY/MATRIX can name
172+
# the specific module instead of a generic "Processing callback".
171173
verb_line = humanize_verb(
172174
kind=kind, sessions=sessions, running_jobs=running_jobs_raw,
173175
last_activity_at=last_act.get(aid),
176+
instances=instances,
174177
)
175178

176179
raw = {

frontend/src/components/dashboard/AgentCard.tsx

Lines changed: 50 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,26 @@
11
/**
22
* @file_name: AgentCard.tsx
3-
* @description: v2.1.1 — agent card with two-tier visibility (collapsed vs
4-
* expanded). Goal: reduce visual noise so the user sees only what matters at
5-
* a glance, and gets richer detail by clicking.
3+
* @description: v2.1.2 — agent card with two-tier visibility (collapsed vs
4+
* expanded) driven by card-body click (inner buttons stopPropagation).
65
*
7-
* COLLAPSED (default state, what most cards always look like):
8-
* ┌─▋ Alpha · 💬 · ⏱ 12s ─────────────────────────────────┐
9-
* │ ▋ Serving 3 users [A][B][C] │ ← verb_line + avatars
10-
* │ ▋ 🔴 1 job failed 3m ago [Retry] [×] │ ← banner (dismissible)
11-
* │ ▋ Queue ████▓░░░ 5 · ✓ 47 ⚠ 1 $0.12 · ▾ more │ ← inline metrics + expand
12-
* └────────────────────────────────────────────────────────┘
6+
* Changes vs v2.1.1:
7+
* - Card body itself is clickable again (regression fix). Inner interactive
8+
* sections (banners, section headers, items, action buttons) all call
9+
* e.stopPropagation() in their own handlers, so clicking a session row
10+
* or a Retry button doesn't bubble up and toggle the whole card.
11+
* - When all attention banners are dismissed in sessionStorage, the status
12+
* rail dims (opacity-40) instead of staying bright red/amber. The
13+
* underlying health is still "error" or "warning" (server-driven), but
14+
* the visual urgency drops once the user has acknowledged. If count
15+
* changes (new failure), banners re-appear and the rail un-dims.
1316
*
14-
* EXPANDED (after click — full sections visible):
15-
* above + SessionSection + JobsSection + Sparkline + RecentFeed
16-
*
17-
* Public non-owned variant: only the header line (no verb, no sections).
18-
* Permission boundary preserved by the discriminated union; here it's an
19-
* `if` early return.
17+
* Layout (owned agents):
18+
* COLLAPSED (default):
19+
* Header · verb_line · banners · inline queue+metrics + ▾ more hint
20+
* EXPANDED:
21+
* above + sessions + jobs + sparkline + recent feed
2022
*/
21-
import type { AgentStatus, OwnedAgentStatus } from '@/types';
23+
import type { AgentStatus, OwnedAgentStatus, AttentionBanner } from '@/types';
2224
import { StatusBadge } from './StatusBadge';
2325
import { DurationDisplay } from './DurationDisplay';
2426
import { ConcurrencyBadge } from './ConcurrencyBadge';
@@ -30,6 +32,7 @@ import { Sparkline } from './Sparkline';
3032
import { RecentFeed } from './RecentFeed';
3133
import { MetricsRow } from './MetricsRow';
3234
import { HEALTH_COLORS } from './healthColors';
35+
import { useAllBannersDismissed, bannerKey } from './expandState';
3336

3437
const HEALTH_TOOLTIP = {
3538
healthy_running: 'Healthy · running',
@@ -89,22 +92,46 @@ function OwnedCard({
8992
expanded: boolean;
9093
onToggleExpand: () => void;
9194
}) {
95+
const banners = agent.attention_banners ?? [];
96+
// Key list for dismiss state lookup — must match the format used in
97+
// AttentionBanners.BannerRow (see expandState.bannerKey).
98+
const allKeys = banners.map((b: AttentionBanner) => bannerKey(agent.agent_id, b.kind, b.message));
99+
const allDismissed = useAllBannersDismissed(allKeys);
100+
92101
const colors = HEALTH_COLORS[agent.health];
102+
// When the user has acknowledged every banner, dim the rail to de-escalate.
103+
// We keep the semantic color (still red/amber) but reduce opacity so the
104+
// card visually quiets down. It un-dims automatically if a new banner appears
105+
// (new signature → new storage key → not dismissed yet → allDismissed=false).
106+
const railDimClass = allDismissed ? 'opacity-40' : '';
93107
const verbLine = agent.verb_line;
94108
const hasSessions = agent.sessions.length > 0;
95109
const hasJobs = agent.running_jobs.length > 0 || agent.pending_jobs.length > 0;
96110
const hasRecent = agent.recent_events.length > 0;
111+
// Same idea on card-body tint: drop the red wash if acknowledged.
112+
const cardTint = allDismissed ? '' : colors.cardTint;
97113

98114
return (
99115
<div
100116
data-testid={`agent-card-${agent.agent_id}`}
101117
data-expanded={expanded ? 'true' : 'false'}
102118
data-health={agent.health}
103-
className={`group flex overflow-hidden rounded-xl border border-[var(--border-primary)] bg-[var(--bg-glass)] transition-colors ${colors.cardTint} ${agent.health === 'idle_long' ? 'opacity-75' : ''}`}
119+
data-banners-dismissed={allDismissed ? 'true' : 'false'}
120+
role="button"
121+
tabIndex={0}
122+
aria-expanded={expanded}
123+
onClick={onToggleExpand}
124+
onKeyDown={(e) => {
125+
if (e.key === 'Enter' || e.key === ' ') {
126+
e.preventDefault();
127+
onToggleExpand();
128+
}
129+
}}
130+
className={`group flex overflow-hidden rounded-xl border border-[var(--border-primary)] bg-[var(--bg-glass)] cursor-pointer hover:border-[var(--accent-primary)] transition-colors ${cardTint} ${agent.health === 'idle_long' ? 'opacity-75' : ''}`}
104131
>
105132
<div
106-
className={`w-1 shrink-0 ${colors.rail}`}
107-
title={HEALTH_TOOLTIP[agent.health]}
133+
className={`w-1 shrink-0 ${colors.rail} ${railDimClass} transition-opacity`}
134+
title={HEALTH_TOOLTIP[agent.health] + (allDismissed ? ' · acknowledged' : '')}
108135
aria-hidden
109136
/>
110137
<div className="flex-1 p-3 min-w-0">
@@ -124,21 +151,16 @@ function OwnedCard({
124151
</div>
125152
)}
126153

127-
{/* Banners */}
128-
<AttentionBanners agentId={agent.agent_id} banners={agent.attention_banners ?? []} />
154+
{/* Banners (each dismissible individually) */}
155+
<AttentionBanners agentId={agent.agent_id} banners={banners} />
129156

130157
{/* Inline summary row — visible in both collapsed + expanded modes */}
131158
<div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1">
132159
<QueueBar queue={agent.queue} compact />
133160
<MetricsRow metrics={agent.metrics_today} />
134-
<button
135-
type="button"
136-
onClick={(e) => { e.stopPropagation(); onToggleExpand(); }}
137-
className="ml-auto text-[11px] text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
138-
aria-expanded={expanded}
139-
>
161+
<span className="ml-auto text-[11px] text-[var(--text-secondary)]">
140162
{expanded ? '▴ less' : '▾ more'}
141-
</button>
163+
</span>
142164
</div>
143165

144166
{/* Expanded sections */}

frontend/src/components/dashboard/AttentionBanners.tsx

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
* removed by the backend; sessionStorage entries become inert.
1313
*/
1414
import type { AttentionBanner } from '@/types';
15-
import { useExpanded } from './expandState';
15+
import { useExpanded, bannerKey } from './expandState';
1616

1717
const LEVEL_STYLE: Record<AttentionBanner['level'], { wrap: string; icon: string; accent: string }> = {
1818
error: {
@@ -52,12 +52,11 @@ export function AttentionBanners({
5252
function BannerRow({ agentId, banner }: { agentId: string; banner: AttentionBanner }) {
5353
// Signature embeds the message (which contains the live count). When the
5454
// underlying count changes the signature changes → banner re-appears.
55-
const sig = encodeURIComponent(banner.message);
55+
// v2.1.2: keep the key format in sync with `bannerKey()` so AgentCard can
56+
// derive rail dimming from the same storage entries.
57+
const key = bannerKey(agentId, banner.kind, banner.message);
5658
// useExpanded stores `true` when expanded. We invert to "dismissed".
57-
const { expanded: dismissed, set } = useExpanded(
58-
`${agentId}:banner:${banner.kind}:${sig}`,
59-
false,
60-
);
59+
const { expanded: dismissed, set } = useExpanded(key, false);
6160
if (dismissed) return null;
6261
const s = LEVEL_STYLE[banner.level];
6362
return (

frontend/src/components/dashboard/expandState.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,3 +64,31 @@ export function useExpanded(key: string, defaultOpen = false) {
6464

6565
return { expanded: value, toggle, set } as const;
6666
}
67+
68+
/**
69+
* v2.1.2 — true iff every provided banner key is marked dismissed in
70+
* sessionStorage. Returns `false` when the banner list is empty (there's
71+
* nothing to dismiss, so "all dismissed" is not a meaningful state).
72+
*
73+
* Reactive: re-renders on `dashboard-expand-changed` so dismissing the
74+
* last banner immediately dims the status rail without waiting for the
75+
* next polling tick.
76+
*/
77+
export function useAllBannersDismissed(
78+
bannerKeys: string[],
79+
): boolean {
80+
return useSyncExternalStore(
81+
subscribe,
82+
() => {
83+
if (bannerKeys.length === 0) return false;
84+
const all = readAll();
85+
return bannerKeys.every((k) => all[k] === true);
86+
},
87+
() => false,
88+
);
89+
}
90+
91+
/** Mirror of the banner sessionStorage key format used by AttentionBanners. */
92+
export function bannerKey(agentId: string, kind: string, message: string): string {
93+
return `${agentId}:banner:${kind}:${encodeURIComponent(message)}`;
94+
}

tests/backend/test_dashboard_v21.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,56 @@ def test_verb_message_bus_and_a2a():
118118
assert humanize_verb("A2A", [], [], None) == "Called by another agent"
119119

120120

121+
# ---- v2.1.2: CALLBACK / SKILL_STUDY / MATRIX surface module info ----
122+
123+
def test_verb_callback_names_module_when_available():
124+
"""Regression for 'Callback ×2 不知道对象是谁' — verb must include module."""
125+
out = humanize_verb(
126+
"CALLBACK", [], [], last_activity_at=None,
127+
instances=[{"module_class": "SocialNetworkModule", "description": "syncing entities"}],
128+
)
129+
assert "SocialNetworkModule" in out
130+
assert "syncing entities" in out
131+
132+
133+
def test_verb_callback_no_instances_fallback():
134+
out = humanize_verb("CALLBACK", [], [], None, instances=[])
135+
assert out == "Processing callback"
136+
137+
138+
def test_verb_callback_multiple_instances_enumerates():
139+
out = humanize_verb(
140+
"CALLBACK", [], [], None,
141+
instances=[
142+
{"module_class": "SocialNetworkModule"},
143+
{"module_class": "JobModule"},
144+
],
145+
)
146+
assert "2" in out
147+
assert "SocialNetworkModule" in out
148+
assert "JobModule" in out
149+
150+
151+
def test_verb_skill_study_uses_instance_info():
152+
out = humanize_verb(
153+
"SKILL_STUDY", [], [], None,
154+
instances=[{"module_class": "SkillModule", "description": "learning curl usage"}],
155+
)
156+
assert "SkillModule" in out
157+
assert "learning curl usage" in out
158+
159+
160+
def test_verb_instance_description_truncated():
161+
long_desc = "x" * 200
162+
out = humanize_verb(
163+
"CALLBACK", [], [], None,
164+
instances=[{"module_class": "Mod", "description": long_desc}],
165+
)
166+
# Should be truncated with ellipsis; not 200+ chars
167+
assert len(out) < 120
168+
assert "…" in out or "..." in out
169+
170+
121171
# ---- build_recent_events_resp -------------------------------------------
122172

123173
def test_recent_events_classifies_error():

0 commit comments

Comments
 (0)