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
8 changes: 5 additions & 3 deletions frontend/src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -630,10 +630,10 @@
"agentRoomLiveness": {
"neverConnected": "{{agentName}} isn't connected yet. Messages sent here will wait until it runs.",
"goneDark": "{{agentName}} hasn't checked in recently. A reply may take longer.",
"unknown": "{{agentName}}'s availability is unknown. A reply may take longer.",
"waiting": "Waiting for {{agentName}} to reply…",
"timedOut": "{{agentName}} hasn't replied yet. Your message is still in this conversation.",
"runCommand": "To connect it, run:"
"runCommand": "To connect it, run:",
"copyCommand": "Copy command"
},
"podAgentLiveness": {
"notConnected": "{{agentName}} isn't connected yet, so it won't answer here until it's running.",
Expand Down Expand Up @@ -713,7 +713,9 @@
"agents_other": "{{count}} agents",
"boardOpen": "board {{count}} open",
"backToPods": "Back to pods",
"invite": "Invite"
"invite": "Invite",
"dm": "DM",
"readOnly": "read only"
},
"team": {
"view": "View pod team",
Expand Down
8 changes: 5 additions & 3 deletions frontend/src/i18n/locales/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -630,10 +630,10 @@
"agentRoomLiveness": {
"neverConnected": "{{agentName}} 尚未连接。发送到这里的消息会等待该席位连接。",
"goneDark": "{{agentName}} 最近没有接入,回复可能需要更长时间。",
"unknown": "{{agentName}} 的可用状态未知,回复可能需要更长时间。",
"waiting": "正在等待 {{agentName}} 回复…",
"timedOut": "{{agentName}} 还没有回复。你的消息仍在此对话中。",
"runCommand": "要连接它,请运行:"
"runCommand": "要连接它,请运行:",
"copyCommand": "复制命令"
},
"podAgentLiveness": {
"notConnected": "{{agentName}} 还没有连接,运行起来之前不会在这里回复。",
Expand Down Expand Up @@ -712,7 +712,9 @@
"agents_other": "{{count}}个智能体",
"boardOpen": "看板{{count}}项待办",
"backToPods": "返回 Pod 列表",
"invite": "邀请"
"invite": "邀请",
"dm": "私信",
"readOnly": "只读"
},
"team": {
"view": "查看 Pod 团队",
Expand Down
81 changes: 75 additions & 6 deletions frontend/src/v2/__tests__/V2ThreadStarter.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,38 @@ describe('V2Thread teaching empty states', () => {
expect(screen.getByText('no messages yet')).toBeInTheDocument();
});

test('a 1:1 room header carries a DM chip and none of the pod furniture', () => {
renderChat(makeAgentRoom());

const meta = document.querySelector('.v2-pod-header__meta:not(.v2-pod-header__meta--compact)');
expect(meta).toHaveTextContent('DM');
expect(document.querySelector('.v2-pod-header__faces')).toBeNull();
expect(document.querySelector('.v2-pod-header__board')).toBeNull();
expect(screen.queryByText('read only')).not.toBeInTheDocument();
});

test('an observer of an agent DM sees a read-only chip and note, and no composer', () => {
renderChat(makeDetail({
pod: { _id: 'agent-dm-1', name: 'Aria and Pixel', type: 'agent-dm' },
members: [
{ _id: 'agent-1', username: 'Aria', isBot: true },
{ _id: 'agent-2', username: 'Pixel', isBot: true },
],
}));

const meta = document.querySelector('.v2-pod-header__meta:not(.v2-pod-header__meta--compact)');
expect(meta).toHaveTextContent('DM');
expect(meta).toHaveTextContent('read only');
// The phone head draws "DM" alone (d215 390 frame).
const compact = document.querySelector('.v2-pod-header__meta--compact');
expect(compact).toHaveTextContent('DM');
expect(compact).not.toHaveTextContent('read only');
const note = screen.getByRole('note', { name: 'Read-only conversation' });
expect(note).toHaveTextContent('Aria and Pixel are talking directly');
expect(note.querySelector('.v2-chat__readonly-icon svg')).not.toBeNull();
expect(screen.queryByPlaceholderText(/Message/)).not.toBeInTheDocument();
});

test('keeps the agent-to-agent empty state unchanged', () => {
renderChat(makeDetail({
pod: { _id: 'agent-dm-1', name: 'Aria and Pixel', type: 'agent-dm' },
Expand Down Expand Up @@ -207,19 +239,34 @@ describe('V2Thread agent-room liveness', () => {
axios.get.mockResolvedValue({ data: { agents: [] } });
});

test('shows an unknown direct seat before the person sends a message', async () => {
test('an unknown reach state shows no row, so a failed status read never reads as offline', async () => {
axios.get.mockResolvedValue({
data: {
agents: [{
agentName: 'openclaw', instanceId: 'aria', displayName: 'Aria', state: 'unknown', isOwner: false,
agentName: 'openclaw', instanceId: 'aria', displayName: 'Aria', state: 'unknown', isOwner: true, fixCommand: 'commonly agent run aria',
}],
},
});
renderChat(makeAgentRoom());

expect(await screen.findByTestId('agent-room-liveness')).toHaveTextContent(
"Aria's availability is unknown",
);
await waitFor(() => expect(axios.get).toHaveBeenCalled());
await act(async () => { await Promise.resolve(); });
expect(screen.queryByTestId('agent-room-liveness')).not.toBeInTheDocument();
});

test('a gone-dark direct seat gets the line with no command, even for its owner', async () => {
axios.get.mockResolvedValue({
data: {
agents: [{
agentName: 'openclaw', instanceId: 'aria', displayName: 'Aria', state: 'gone-dark', isOwner: true, fixCommand: 'commonly agent run aria',
}],
},
});
renderChat(makeAgentRoom());

expect(await screen.findByTestId('agent-room-liveness')).toHaveTextContent("Aria hasn't checked in recently");
expect(screen.queryByTestId('agent-room-fix-command')).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Copy command' })).not.toBeInTheDocument();
});

test('a never-connected direct seat says so and shows the owner the command to run', async () => {
Expand All @@ -237,6 +284,22 @@ describe('V2Thread agent-room liveness', () => {
expect(screen.getByTestId('agent-room-fix-command')).toHaveTextContent('commonly agent run aria');
});

test('the owner copies the fix command with one key, which confirms the copy', async () => {
navigator.clipboard.writeText.mockClear();
axios.get.mockResolvedValue({
data: {
agents: [{
agentName: 'openclaw', instanceId: 'aria', displayName: 'Aria', state: 'never-connected', isOwner: true, fixCommand: 'commonly agent run aria',
}],
},
});
renderChat(makeAgentRoom());

fireEvent.click(await screen.findByRole('button', { name: 'Copy command' }));
await waitFor(() => expect(navigator.clipboard.writeText).toHaveBeenCalledWith('commonly agent run aria'));
expect(await screen.findByRole('button', { name: 'Copied!' })).toBeInTheDocument();
});

test('a never-connected direct seat shows a non-owner no command', async () => {
axios.get.mockResolvedValue({
data: {
Expand Down Expand Up @@ -325,8 +388,14 @@ describe('V2Thread agent-room liveness', () => {
});
expect(screen.getByTestId('agent-reply-wait')).toHaveTextContent('Waiting for Aria to reply');

expect(screen.getByTestId('agent-reply-wait')).toHaveClass('v2-chat__agent-room-status--wait');

act(() => { jest.advanceTimersByTime(120_000); });
expect(screen.getByTestId('agent-reply-wait')).toHaveTextContent("Aria hasn't replied yet");
const timedOut = screen.getByTestId('agent-reply-wait');
expect(timedOut).toHaveTextContent("Aria hasn't replied yet");
// Timed out drops the pulsing dot and the mono face (d215 frame).
expect(timedOut.querySelector('.v2-chat__agent-room-status-dot')).toBeNull();
expect(timedOut).not.toHaveClass('v2-chat__agent-room-status--wait');
jest.useRealTimers();
});
});
Expand Down
15 changes: 11 additions & 4 deletions frontend/src/v2/__tests__/v2-layout-invariants.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -648,11 +648,18 @@ describe('v2 layout invariants (CSS rule presence)', () => {
// liveness and the bounded reply wait outside the scrollable transcript
// and outside the textarea, so both remain visible while composing.
const status = ruleBody(v2, '.v2-chat__agent-room-status');
expect(status).toContain('margin: 8px 24px 0');
expect(status).toContain('font-size: 12px');
expect(status).toContain('margin: 6px 18px 0');
expect(status).toContain('flex: none');
const waiting = ruleBody(v2, '.v2-chat__agent-room-status--wait');
expect(waiting).toContain('background: var(--v2-surface-hover)');
expect(waiting).toContain('font-family: var(--v2-font-mono)');
expect(waiting).toContain('var(--v2-font-mono)');
// The waiting dot is ink, never the success green (d215 frame).
expect(ruleBody(v2, '.v2-chat__agent-room-status-dot')).toContain('background: var(--v2-ink)');
// The copy key is a 44px target on a phone (an exception to the 32px desktop key).
const phone = mediaBlockContaining(v2, '.v2-root button.v2-chat__agent-room-copy { width: 44px');
expect(phone).toContain('.v2-root button.v2-chat__agent-room-copy { width: 44px; height: 44px; }');
// The read-only note replaces the composer, so on a phone it clears the
// fixed 54px tab bar as the composer does.
expect(mediaBlockContaining(v2, '.v2-chat__readonly { margin')).toContain('.v2-chat__readonly { margin: 4px 12px 66px; }');
});

test('runtime vocabulary stays off Your Team cards (ADR-022 D1, ratified)', () => {
Expand Down
14 changes: 14 additions & 0 deletions frontend/src/v2/chat/chat-pane.css
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,20 @@
white-space: nowrap;
}

/* A 1:1 room's header chips, "DM" and "read only" (d215 .chip). */
.v2-pod-header__chip {
display: inline-flex;
align-items: center;
height: 24px;
padding: 0 8px;
border-radius: 7px;
font: 500 11.5px/1 var(--v2-font-mono);
color: var(--v2-text-secondary);
background: var(--v2-surface);
box-shadow: inset 0 0 0 1px var(--v2-border-soft);
white-space: nowrap;
}

/* The compact meta is the phone's; both carry the base class, so the
compact rule must out-rank the base `display: flex` above it. */
.v2-pod-header__meta.v2-pod-header__meta--compact { display: none; }
Expand Down
Loading
Loading