diff --git a/docs/design/artboards/d215/index.html b/docs/design/artboards/d215/index.html index da0f1f67a..4955b2933 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 5f4c1d885..be3d8c7bb 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 a1d5fd6f8..fe922c0d7 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 7bfddeb8d..516ddb05e 100644 --- a/frontend/src/v2/__tests__/V2PodAgentLiveness.test.tsx +++ b/frontend/src/v2/__tests__/V2PodAgentLiveness.test.tsx @@ -108,6 +108,67 @@ 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); + 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); + }); + + 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('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/__tests__/v2-layout-invariants.test.ts b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts index d96ee748a..c47dd5ec9 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'); + }); }); diff --git a/frontend/src/v2/chat/chat-states.css b/frontend/src/v2/chat/chat-states.css index 9faed039c..a85ec3f93 100644 --- a/frontend/src/v2/chat/chat-states.css +++ b/frontend/src/v2/chat/chat-states.css @@ -304,6 +304,71 @@ 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; + /* 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 { 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 67de26964..08f38ac27 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,24 @@ 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 }) + : 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: new Intl.NumberFormat(i18n.resolvedLanguage || i18n.language || 'en').format(awayNames.length - 2), + }); const showAwaitingAgentReply = Boolean( isAgentRoom && awaitingAgentReply && awaitingAgentReply.podId === pod._id, ); @@ -2248,23 +2264,41 @@ 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. */} + {/* 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} + + +
    + {podAgentLiveness.map((row) => ( +
  • {podAgentLivenessLine(row)}
  • + ))} +
+
+ )} {showAwaitingAgentReply && awaitingAgentReply && (