Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions docs/design/artboards/d215/index.html

Large diffs are not rendered by default.

5 changes: 4 additions & 1 deletion frontend/src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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…",
Expand Down
5 changes: 4 additions & 1 deletion frontend/src/i18n/locales/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -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}} 正在思考…",
Expand Down
61 changes: 61 additions & 0 deletions frontend/src/v2/__tests__/V2PodAgentLiveness.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
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({
Expand Down
15 changes: 15 additions & 0 deletions frontend/src/v2/__tests__/v2-layout-invariants.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
65 changes: 65 additions & 0 deletions frontend/src/v2/chat/chat-states.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
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);
}
Expand Down
62 changes: 48 additions & 14 deletions frontend/src/v2/components/V2Thread.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,6 @@ interface AgentStateRow {
fixCommand?: string;
}
const REACH_IS_ACTIVE = new Set<AgentReachState>(['listening', 'reachable']);
const POD_LIVENESS_MAX_ROWS = 3;

interface DirectAgentIdentity {
username: string;
Expand Down Expand Up @@ -231,7 +230,7 @@ interface V2ThreadProps {
}

const V2Thread: React.FC<V2ThreadProps> = ({ 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,
Expand Down Expand Up @@ -1582,7 +1581,24 @@ const V2Thread: React.FC<V2ThreadProps> = ({ 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,
);
Expand Down Expand Up @@ -2248,23 +2264,41 @@ const V2Thread: React.FC<V2ThreadProps> = ({ detail, firstRunVisible = false, in
</div>
</div>
)}
{podAgentLiveness.map((row) => (
{podAgentLiveness.length === 1 && (
<div
key={`${row.agentName}:${row.instanceId}`}
className="v2-chat__agent-room-status"
data-testid="pod-agent-liveness"
role="status"
>
<div className="v2-chat__agent-room-status-body">
{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),
})}
</div>
<div className="v2-chat__agent-room-status-body">{podAgentLivenessLine(podAgentLiveness[0])}</div>
</div>
))}
)}
{/* 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 && (
<span className="v2-chat__agents-away-live" role="status">{podAgentsAwaySummary}</span>
)}
{podAgentLiveness.length > 1 && (
<details className="v2-chat__agent-room-status v2-chat__agents-away" data-testid="pod-agents-away">
<summary>
<span className="v2-chat__agent-room-status-icon" aria-hidden="true">
<ShellGlyph name="info" size={16} />
</span>
<span className="v2-chat__agent-room-status-body">{podAgentsAwaySummary}</span>
<span className="v2-chat__agents-away-chev" aria-hidden="true">
<ShellGlyph name="chevron" size={16} />
</span>
</summary>
<ul>
{podAgentLiveness.map((row) => (
<li key={`${row.agentName}:${row.instanceId}`} data-testid="pod-agent-liveness">{podAgentLivenessLine(row)}</li>
))}
</ul>
</details>
)}
{showAwaitingAgentReply && awaitingAgentReply && (
<div
className={`v2-chat__agent-room-status${awaitingAgentReply.timedOut ? '' : ' v2-chat__agent-room-status--wait'}`}
Expand Down
Loading