From fe6fb70743af67f77068281fa473d6ceb6fa71e3 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 1 Nov 2026 12:07:28 -0800 Subject: [PATCH 1/3] feat(chat): collapse stacked "last checked" notices into one line When more than one agent in a pod is away, the composer used to carry one notice per agent. On a phone, three of them covered about a third of the screen. They now collapse into a single line above the composer, "Flint, Swift and 1 other aren't answering right now", which opens to the per-agent lines. A single away agent keeps the existing notice. The artboard draws it first: a new "Agents away" block in the chat section (1440 collapsed, 390 collapsed and open), in the existing notice style. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015AKgpMD6Qh13f7fj6mbGT3 --- docs/design/artboards/d215/index.html | 36 +++++++++++++ frontend/src/i18n/locales/en.json | 5 +- frontend/src/i18n/locales/zh-CN.json | 5 +- .../v2/__tests__/V2PodAgentLiveness.test.tsx | 31 ++++++++++++ frontend/src/v2/chat/chat-states.css | 50 +++++++++++++++++++ frontend/src/v2/components/V2Thread.tsx | 49 ++++++++++++++---- 6 files changed, 163 insertions(+), 13 deletions(-) diff --git a/docs/design/artboards/d215/index.html b/docs/design/artboards/d215/index.html index da0f1f67a6..4955b29333 100644 --- a/docs/design/artboards/d215/index.html +++ b/docs/design/artboards/d215/index.html @@ -293,6 +293,14 @@ .rstat { display: flex; align-items: flex-start; gap: 10px; margin: 6px 18px 0; padding: 10px 12px; border-radius: 10px; background: var(--p); box-shadow: inset 0 0 0 1px var(--d); font-size: 12.5px; line-height: 1.5; color: var(--se); flex: none; } .rstat > .i { width: 16px; height: 16px; margin-top: 2px; flex: none; color: var(--mu); } .rstat > .dot { margin: 7px 3px 0; } +.rstat--away { display: block; padding: 0; } +.rstat--away > summary { list-style: none; cursor: pointer; display: flex; align-items: center; gap: 10px; min-height: 40px; padding: 0 12px; } +.rstat--away > summary::-webkit-details-marker { display: none; } +.rstat--away > summary > .i { width: 16px; height: 16px; flex: none; color: var(--mu); } +.rstat--away > summary > span { flex: 1; min-width: 0; } +.rstat--away > summary > .chev { color: var(--ph); transition: transform 160ms; } +.rstat--away[open] > summary > .chev { transform: rotate(180deg); } +.rstat--away > ul { list-style: none; margin: 0; padding: 0 12px 10px 38px; display: grid; gap: 4px; } .rstat--mono { font: 500 12px/1.5 var(--f-mono); } .rstat .rlede { margin-top: 6px; } .rcmd { display: flex; align-items: center; gap: 8px; margin-top: 6px; padding: 4px 4px 4px 10px; border-radius: 8px; background: var(--t); box-shadow: inset 0 0 0 1px var(--d); } @@ -838,6 +846,34 @@

Same look. Less said.

Follow-ups TASK-123 (components) and TASK-124 (secondary styles) are unassigned. The unmatched values need a design call before they can move, and the avatar palette is brand data, so it needs one too. The 1440 screenshots aren't attached: this agent has no browser, so that check is still owed.

Halclaude · 1:30

@alex-kim TASK-125 (empty-state illustrations) is a brand and design change, and I work on the backend, not design. Send it to Ivy or Nova unless you want me to take it.

Message Northwind
Pods
157Needs you
Team
Settings
+ +

Agents away

two or more agents in the pod are not answering; one agent keeps its own line
Collapsed · 1440three agents away, one line
+
+
+
NWNorthwind — onboarding, search, release prep120now
MAMobile app — offline sync113m
RTRelease train4m
DSDocs site127m
DRDesign review37m
LWLaunch week40m
PAPayments35h
REResearch105h
+
+
Northwind — onboarding, search, release prep + 164 + +26 +
Adaclaude · 1:16 AM

@alex-kim Reviewed the three stacked PRs again after the rebase:

+
  • #405 at 7d1e4b2093: approved, review posted. I read the production diff; I did not run the suite. Bo's approval is at this head too.
  • +
  • #406 at 5c2e9a174: approved, review posted. Cy's approval is at this head.
  • +
  • #407 at 9b0f3d648: approved, review posted. It is stacked on #406, so #406 should merge first.
+

I could not find my earlier review notes on #405, #406 or #407 in the pod after 02:18Z, so I did not review from memory. Those three are fresh reads at the exact heads. If you have the earlier notes, send them and I'll check it against these.

Fernclaude · 1:18 AM

@alex-kim TASK-114 is green. PR #412 head is 3f9c2a1e, all 24 checks pass, and the review thread is answered in-thread. Ready for you to merge.

Alexhuman · 1:21 AM

Merging now. Thanks, Fern.

1 reply
Pike claimed TASK-1181:19
Juno claimed TASK-1201:19
Moss claimed TASK-1171:19
Reed claimed TASK-1211:19
Reedcodex · 1:19 AM

Claimed TASK-121 (fold long chat messages). Starting on the message component now, with before and after at 1440 and 390 and a design review as the done line.

Sageclaude · 1:28 AM

@alex-kim TASK-122 part 1 is PR #415 (branch sage/dark-mode-tokens). It maps the 76 colour values that exactly match an existing token to a variable, and the layout tests move with them. Hardcoded colours go 498 → 422. The task stays open.

+

Follow-ups TASK-123 (components) and TASK-124 (secondary styles) are unassigned. The unmatched values need a design call before they can move, and the avatar palette is brand data, so it needs one too. The 1440 screenshots aren't attached: this agent has no browser, so that check is still owed.

fay is thinking
Flint, Swift and 1 other aren't answering right now
  • Flint last checked in 5h ago. It answers when it's back.
  • Swift last checked in 5h ago. It answers when it's back.
  • Dart last checked in 11h ago. It answers when it's back.
Message Northwind
+
+
+
Needs you 118 +
Export: keep the time zone in…P2
Search: highlight matches in the…P2
Invites: resend after the link e…P1
115 more ›
+
Agents 24
+
Files 52
+
Who can join +
+
Collapsed · 390three agents away, one line
Northwind166
+
Fernclaude · 1:18

@alex-kim TASK-114 is green. PR #412, all 24 checks pass. Ready to merge.

Alexhuman · 1:21

Merging now. Thanks, Fern.

1 reply
Pike claimed TASK-1181:19
Juno claimed TASK-1201:19
Moss claimed TASK-1171:19
Reed claimed TASK-1211:19
Sageclaude · 1:28

@alex-kim TASK-122 part 1 is PR #415 (branch sage/dark-mode-tokens). It maps the 76 colour values that exactly match an existing token to a variable, and the layout tests move with them. Hardcoded colours go 498 → 422. The task stays open.

+

Follow-ups TASK-123 (components) and TASK-124 (secondary styles) are unassigned. The unmatched values need a design call before they can move, and the avatar palette is brand data, so it needs one too. The 1440 screenshots aren't attached: this agent has no browser, so that check is still owed.

Halclaude · 1:30

@alex-kim TASK-125 (empty-state illustrations) is a brand and design change, and I work on the backend, not design. Send it to Ivy or Nova unless you want me to take it.

Flint, Swift and 1 other aren't answering right now
  • Flint last checked in 5h ago. It answers when it's back.
  • Swift last checked in 5h ago. It answers when it's back.
  • Dart last checked in 11h ago. It answers when it's back.
Message Northwind
Pods
157Needs you
Team
Settings
Opened · 390three agents away, one line
Northwind166
+
Fernclaude · 1:18

@alex-kim TASK-114 is green. PR #412, all 24 checks pass. Ready to merge.

Alexhuman · 1:21

Merging now. Thanks, Fern.

1 reply
Pike claimed TASK-1181:19
Juno claimed TASK-1201:19
Moss claimed TASK-1171:19
Reed claimed TASK-1211:19
Sageclaude · 1:28

@alex-kim TASK-122 part 1 is PR #415 (branch sage/dark-mode-tokens). It maps the 76 colour values that exactly match an existing token to a variable, and the layout tests move with them. Hardcoded colours go 498 → 422. The task stays open.

+

Follow-ups TASK-123 (components) and TASK-124 (secondary styles) are unassigned. The unmatched values need a design call before they can move, and the avatar palette is brand data, so it needs one too. The 1440 screenshots aren't attached: this agent has no browser, so that check is still owed.

Halclaude · 1:30

@alex-kim TASK-125 (empty-state illustrations) is a brand and design change, and I work on the backend, not design. Send it to Ivy or Nova unless you want me to take it.

Flint, Swift and 1 other aren't answering right now
  • Flint last checked in 5h ago. It answers when it's back.
  • Swift last checked in 5h ago. It answers when it's back.
  • Dart last checked in 11h ago. It answers when it's back.
Message Northwind
Pods
157Needs you
Team
Settings

Agent room and agent DM

/v2/pods/:podId — agent-room, agent-dm
diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 5f4c1d8852..be3d8c7bbc 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -640,7 +640,10 @@ }, "podAgentLiveness": { "notConnected": "{{agentName}} isn't connected yet, so it won't answer here until it's running.", - "offlineSince": "{{agentName}} last checked in {{age}}. It answers when it's back." + "offlineSince": "{{agentName}} last checked in {{age}}. It answers when it's back.", + "awayTwo": "{{first}} and {{second}} aren't answering right now", + "awayMany_one": "{{first}}, {{second}} and {{formattedCount}} other aren't answering right now", + "awayMany_other": "{{first}}, {{second}} and {{formattedCount}} others aren't answering right now" }, "typing": { "one": "{{name}} is thinking…", diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index a1d5fd6f83..fe922c0d78 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -640,7 +640,10 @@ }, "podAgentLiveness": { "notConnected": "{{agentName}} 还没有连接,运行起来之前不会在这里回复。", - "offlineSince": "{{agentName}} 上次连接是{{age}},恢复连接后会回复。" + "offlineSince": "{{agentName}} 上次连接是{{age}},恢复连接后会回复。", + "awayTwo": "{{first}} 和 {{second}} 暂时不会回复", + "awayMany_one": "{{first}}、{{second}} 和另外{{formattedCount}}个智能体暂时不会回复", + "awayMany_other": "{{first}}、{{second}} 和另外{{formattedCount}}个智能体暂时不会回复" }, "typing": { "one": "{{name}} 正在思考…", diff --git a/frontend/src/v2/__tests__/V2PodAgentLiveness.test.tsx b/frontend/src/v2/__tests__/V2PodAgentLiveness.test.tsx index 7bfddeb8d9..f631d650aa 100644 --- a/frontend/src/v2/__tests__/V2PodAgentLiveness.test.tsx +++ b/frontend/src/v2/__tests__/V2PodAgentLiveness.test.tsx @@ -108,6 +108,37 @@ describe('group pod agent liveness', () => { expect(screen.queryByTestId('pod-agent-liveness')).not.toBeInTheDocument(); }); + test('two agents away fold into one closed line that names both and opens to their rows', async () => { + mockStates([ + { agentName: 'flint', instanceId: 'default', displayName: 'Flint', state: 'gone-dark', lastUsedAt: HOURS_AGO_3, isOwner: false }, + { agentName: 'swift', instanceId: 'default', displayName: 'Swift', state: 'never-connected', lastUsedAt: null, isOwner: false }, + ]); + renderChat(makeDetail()); + + const away = await screen.findByTestId('pod-agents-away'); + expect(away.tagName).toBe('DETAILS'); + expect(away).not.toHaveAttribute('open'); + expect(away.querySelector('summary')).toHaveTextContent("Flint and Swift aren't answering right now"); + const rows = screen.getAllByTestId('pod-agent-liveness'); + expect(rows.map((r) => r.textContent)).toEqual([ + "Flint last checked in 3h ago. It answers when it's back.", + "Swift isn't connected yet, so it won't answer here until it's running.", + ]); + expect(rows.every((r) => away.contains(r))).toBe(true); + expect(document.querySelectorAll('.v2-chat__agent-room-status')).toHaveLength(1); + }); + + test('three agents away name two and count the rest', async () => { + mockStates(['Flint', 'Swift', 'Dart'].map((name) => ({ + agentName: name.toLowerCase(), instanceId: 'default', displayName: name, state: 'gone-dark', lastUsedAt: HOURS_AGO_3, isOwner: false, + }))); + renderChat(makeDetail()); + + const away = await screen.findByTestId('pod-agents-away'); + expect(away.querySelector('summary')).toHaveTextContent("Flint, Swift and 1 other aren't answering right now"); + expect(screen.getAllByTestId('pod-agent-liveness')).toHaveLength(3); + }); + test('a 1:1 agent room keeps its own row and never doubles it', async () => { mockStates([{ agentName: 'sam-agent', instanceId: 'default', displayName: 'Sam Agent', state: 'never-connected', lastUsedAt: null, isOwner: true }]); renderChat(makeDetail({ diff --git a/frontend/src/v2/chat/chat-states.css b/frontend/src/v2/chat/chat-states.css index 9faed039cb..b28c7077db 100644 --- a/frontend/src/v2/chat/chat-states.css +++ b/frontend/src/v2/chat/chat-states.css @@ -304,6 +304,56 @@ min-width: 0; } +/* Two or more agents away: one line that opens to the list (d215 #s-chat + "Agents away"). The row keeps the notice look; the summary is the line. */ +.v2-chat__agent-room-status.v2-chat__agents-away { + display: block; + padding: 0; +} + +.v2-chat__agents-away > summary { + list-style: none; + cursor: pointer; + display: flex; + align-items: center; + gap: 10px; + min-height: 40px; + padding: 0 12px; + border-radius: 10px; +} + +.v2-chat__agents-away > summary::-webkit-details-marker { + display: none; +} + +.v2-chat__agents-away > summary:focus-visible { + outline: none; + box-shadow: var(--v2-focus-ring); +} + +.v2-chat__agents-away > summary .v2-chat__agent-room-status-icon { + margin-top: 0; +} + +.v2-chat__agents-away-chev { + display: flex; + flex: none; + color: var(--v2-text-placeholder); + transition: transform 160ms; +} + +.v2-chat__agents-away[open] .v2-chat__agents-away-chev { + transform: rotate(180deg); +} + +.v2-chat__agents-away > ul { + list-style: none; + margin: 0; + padding: 0 12px 10px 38px; + display: grid; + gap: 4px; +} + .v2-chat__agent-room-status--wait { font: 500 12px/1.5 var(--v2-font-mono); } diff --git a/frontend/src/v2/components/V2Thread.tsx b/frontend/src/v2/components/V2Thread.tsx index 67de26964b..a168697463 100644 --- a/frontend/src/v2/components/V2Thread.tsx +++ b/frontend/src/v2/components/V2Thread.tsx @@ -1583,6 +1583,20 @@ const V2Thread: React.FC = ({ detail, firstRunVisible = false, in typeof s?.agentName === 'string' && s.agentName && (s.state === 'never-connected' || s.state === 'gone-dark') )).slice(0, POD_LIVENESS_MAX_ROWS); + const podAgentLivenessLine = (row: typeof podAgentLiveness[number]) => ( + row.state === 'never-connected' || !row.lastUsedAt + ? t('podChat.podAgentLiveness.notConnected', { agentName: row.displayName || row.agentName }) + : t('podChat.podAgentLiveness.offlineSince', { + agentName: row.displayName || row.agentName, + age: localizeRelativeTime(row.lastUsedAt, t), + }) + ); + const awayNames = podAgentLiveness.map((row) => row.displayName || row.agentName); + const podAgentsAwaySummary = awayNames.length === 2 + ? t('podChat.podAgentLiveness.awayTwo', { first: awayNames[0], second: awayNames[1] }) + : t('podChat.podAgentLiveness.awayMany', { + first: awayNames[0], second: awayNames[1], count: awayNames.length - 2, formattedCount: awayNames.length - 2, + }); const showAwaitingAgentReply = Boolean( isAgentRoom && awaitingAgentReply && awaitingAgentReply.podId === pod._id, ); @@ -2248,23 +2262,36 @@ const V2Thread: React.FC = ({ detail, firstRunVisible = false, in )} - {podAgentLiveness.map((row) => ( + {podAgentLiveness.length === 1 && (
-
- {row.state === 'never-connected' || !row.lastUsedAt - ? t('podChat.podAgentLiveness.notConnected', { agentName: row.displayName || row.agentName }) - : t('podChat.podAgentLiveness.offlineSince', { - agentName: row.displayName || row.agentName, - age: localizeRelativeTime(row.lastUsedAt, t), - })} -
+
{podAgentLivenessLine(podAgentLiveness[0])}
- ))} + )} + {/* Two or more agents away fold into one line that opens to the + list (d215 #s-chat "Agents away"), so the notices never stack + over the transcript. */} + {podAgentLiveness.length > 1 && ( +
+ + + {podAgentsAwaySummary} + + +
    + {podAgentLiveness.map((row) => ( +
  • {podAgentLivenessLine(row)}
  • + ))} +
+
+ )} {showAwaitingAgentReply && awaitingAgentReply && (
Date: Sun, 1 Nov 2026 12:11:10 -0800 Subject: [PATCH 2/3] fix(chat): count and list every away agent, announce from outside the summary The away line took its count after the old three-row cap, so five away agents read "Flint, Swift and 1 other" and two of them were listed nowhere. The cap existed to stop notices stacking; the collapsed line does that now, so the count and the list use every away agent, and a long list scrolls inside the line. A summary's children are presentational, so a live region inside it likely announced nothing. The announcement moves to a visually hidden status beside the line. The remainder count goes through Intl.NumberFormat like the other formattedCount call sites. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015AKgpMD6Qh13f7fj6mbGT3 --- .../v2/__tests__/V2PodAgentLiveness.test.tsx | 26 +++++++++++++++++++ frontend/src/v2/chat/chat-states.css | 15 +++++++++++ frontend/src/v2/components/V2Thread.tsx | 17 ++++++++---- 3 files changed, 53 insertions(+), 5 deletions(-) diff --git a/frontend/src/v2/__tests__/V2PodAgentLiveness.test.tsx b/frontend/src/v2/__tests__/V2PodAgentLiveness.test.tsx index f631d650aa..93412a9f83 100644 --- a/frontend/src/v2/__tests__/V2PodAgentLiveness.test.tsx +++ b/frontend/src/v2/__tests__/V2PodAgentLiveness.test.tsx @@ -139,6 +139,32 @@ describe('group pod agent liveness', () => { expect(screen.getAllByTestId('pod-agent-liveness')).toHaveLength(3); }); + test('five agents away count every one and list every one, past the old three-row cap', async () => { + mockStates(['Flint', 'Swift', 'Dart', 'Echo', 'Kite'].map((name) => ({ + agentName: name.toLowerCase(), instanceId: 'default', displayName: name, state: 'gone-dark', lastUsedAt: HOURS_AGO_3, isOwner: false, + }))); + renderChat(makeDetail()); + + const away = await screen.findByTestId('pod-agents-away'); + expect(away.querySelector('summary')).toHaveTextContent("Flint, Swift and 3 others aren't answering right now"); + const rows = screen.getAllByTestId('pod-agent-liveness'); + expect(rows).toHaveLength(5); + expect(rows[4]).toHaveTextContent('Kite last checked'); + }); + + test('the away line is announced from a live region outside the summary', async () => { + mockStates(['Flint', 'Swift'].map((name) => ({ + agentName: name.toLowerCase(), instanceId: 'default', displayName: name, state: 'gone-dark', lastUsedAt: HOURS_AGO_3, isOwner: false, + }))); + renderChat(makeDetail()); + + const away = await screen.findByTestId('pod-agents-away'); + const live = screen.getByRole('status'); + expect(live).toHaveTextContent("Flint and Swift aren't answering right now"); + expect(away.contains(live)).toBe(false); + expect(away.querySelector('summary [role="status"]')).toBeNull(); + }); + test('a 1:1 agent room keeps its own row and never doubles it', async () => { mockStates([{ agentName: 'sam-agent', instanceId: 'default', displayName: 'Sam Agent', state: 'never-connected', lastUsedAt: null, isOwner: true }]); renderChat(makeDetail({ diff --git a/frontend/src/v2/chat/chat-states.css b/frontend/src/v2/chat/chat-states.css index b28c7077db..a85ec3f93b 100644 --- a/frontend/src/v2/chat/chat-states.css +++ b/frontend/src/v2/chat/chat-states.css @@ -352,6 +352,21 @@ padding: 0 12px 10px 38px; display: grid; gap: 4px; + /* Every away agent is listed; a long list scrolls inside the line. */ + max-height: 132px; + overflow-y: auto; +} + +.v2-chat__agents-away-live { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + border: 0; } .v2-chat__agent-room-status--wait { diff --git a/frontend/src/v2/components/V2Thread.tsx b/frontend/src/v2/components/V2Thread.tsx index a168697463..08f38ac278 100644 --- a/frontend/src/v2/components/V2Thread.tsx +++ b/frontend/src/v2/components/V2Thread.tsx @@ -88,7 +88,6 @@ interface AgentStateRow { fixCommand?: string; } const REACH_IS_ACTIVE = new Set(['listening', 'reachable']); -const POD_LIVENESS_MAX_ROWS = 3; interface DirectAgentIdentity { username: string; @@ -231,7 +230,7 @@ interface V2ThreadProps { } const V2Thread: React.FC = ({ detail, firstRunVisible = false, inspectorCollapsed, onToggleInspector, onOpenMember, onOpenInvite, onOpenFile, onBack, onDecisionSettled }) => { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); const { pod, members, messages, agents, sendMessage, loading, error, sendError, hasMore, loadingOlder, loadOlder, @@ -1582,7 +1581,7 @@ const V2Thread: React.FC = ({ detail, firstRunVisible = false, in const podAgentLiveness = isAgentRoom || isAgentDm ? [] : agentStates.filter((s) => ( typeof s?.agentName === 'string' && s.agentName && (s.state === 'never-connected' || s.state === 'gone-dark') - )).slice(0, POD_LIVENESS_MAX_ROWS); + )); const podAgentLivenessLine = (row: typeof podAgentLiveness[number]) => ( row.state === 'never-connected' || !row.lastUsedAt ? t('podChat.podAgentLiveness.notConnected', { agentName: row.displayName || row.agentName }) @@ -1595,7 +1594,10 @@ const V2Thread: React.FC = ({ detail, firstRunVisible = false, in const podAgentsAwaySummary = awayNames.length === 2 ? t('podChat.podAgentLiveness.awayTwo', { first: awayNames[0], second: awayNames[1] }) : t('podChat.podAgentLiveness.awayMany', { - first: awayNames[0], second: awayNames[1], count: awayNames.length - 2, formattedCount: awayNames.length - 2, + first: awayNames[0], + second: awayNames[1], + count: awayNames.length - 2, + formattedCount: new Intl.NumberFormat(i18n.resolvedLanguage || i18n.language || 'en').format(awayNames.length - 2), }); const showAwaitingAgentReply = Boolean( isAgentRoom && awaitingAgentReply && awaitingAgentReply.podId === pod._id, @@ -2274,13 +2276,18 @@ const V2Thread: React.FC = ({ detail, firstRunVisible = false, in {/* Two or more agents away fold into one line that opens to the list (d215 #s-chat "Agents away"), so the notices never stack over the transcript. */} + {/* A summary's children are presentational, so the live region + sits beside the line rather than inside it. */} + {podAgentLiveness.length > 1 && ( + {podAgentsAwaySummary} + )} {podAgentLiveness.length > 1 && (
- {podAgentsAwaySummary} + {podAgentsAwaySummary} From 62d05931a97ccb16f49df550b677ecb0a591a5b1 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 1 Nov 2026 12:15:40 -0800 Subject: [PATCH 3/3] test(chat): prove the away line opens, and pin its layout rules The rows are in the DOM while the line is closed, so asserting they exist did not prove anyone can reveal them. The test now checks they are hidden until the summary is clicked and visible after. The disclosure's block display, flex summary, 40px line, chevron and scrolling list are load-bearing and invisible to jsdom layout, so they get presence assertions alongside the other chat-sheet invariants. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015AKgpMD6Qh13f7fj6mbGT3 --- .../src/v2/__tests__/V2PodAgentLiveness.test.tsx | 4 ++++ .../src/v2/__tests__/v2-layout-invariants.test.ts | 15 +++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/frontend/src/v2/__tests__/V2PodAgentLiveness.test.tsx b/frontend/src/v2/__tests__/V2PodAgentLiveness.test.tsx index 93412a9f83..516ddb05e8 100644 --- a/frontend/src/v2/__tests__/V2PodAgentLiveness.test.tsx +++ b/frontend/src/v2/__tests__/V2PodAgentLiveness.test.tsx @@ -125,6 +125,10 @@ describe('group pod agent liveness', () => { "Swift isn't connected yet, so it won't answer here until it's running.", ]); expect(rows.every((r) => away.contains(r))).toBe(true); + rows.forEach((r) => expect(r).not.toBeVisible()); + fireEvent.click(away.querySelector('summary')); + expect(away).toHaveAttribute('open'); + rows.forEach((r) => expect(r).toBeVisible()); expect(document.querySelectorAll('.v2-chat__agent-room-status')).toHaveLength(1); }); diff --git a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts index d96ee748ae..c47dd5ec97 100644 --- a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts +++ b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts @@ -5836,4 +5836,19 @@ describe('chat sheets lifted out of v2.css (S3)', () => { test('every token the chat sheets read is defined', () => { for (const sheet of chatSheets) expect(sheet).not.toMatch(/var\(--v2-text\)/); }); + + test('two or more agents away stay one 40px line that opens to a scrolling list', () => { + const states = chatSheets[4]; + // The notice row is a flex row; as a disclosure it must be a block, or the summary and the list sit side by side. + expect(ruleBody(states, '.v2-chat__agent-room-status.v2-chat__agents-away')).toContain('display: block'); + const summary = ruleBody(states, '.v2-chat__agents-away > summary'); + expect(summary).toContain('display: flex'); + expect(summary).toContain('min-height: 40px'); + expect(ruleBody(states, '.v2-chat__agents-away-chev')).toContain('flex: none'); + expect(ruleBody(states, '.v2-chat__agents-away[open] .v2-chat__agents-away-chev')).toContain('transform: rotate(180deg)'); + const list = ruleBody(states, '.v2-chat__agents-away > ul'); + expect(list).toContain('display: grid'); + expect(list).toContain('max-height: 132px'); + expect(list).toContain('overflow-y: auto'); + }); });