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
5 changes: 5 additions & 0 deletions frontend/src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -412,6 +412,11 @@
"cancelReply": "Cancel reply",
"replyingTo": "Replying to <author>{{author}}</author>:",
"deliveryHint": "No agent was notified — @mention one to get a reply. Try <handle>@{{handle}}</handle>.",
"noAgentsInPod": {
"kicker": "no agent in this pod",
"body": "Nobody here answers yet — people will see this when they open the pod.",
"action": "Add an agent"
},
"agentRoomLiveness": {
"neverConnected": "{{agentName}} isn't connected. Messages sent here will wait for this seat to connect.",
"goneDark": "{{agentName}} hasn't checked in recently. A reply may take longer.",
Expand Down
5 changes: 5 additions & 0 deletions frontend/src/i18n/locales/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -412,6 +412,11 @@
"cancelReply": "取消回复",
"replyingTo": "回复 <author>{{author}}</author>:",
"deliveryHint": "尚未通知任何智能体。@提及一个智能体即可让它回复,例如 <handle>@{{handle}}</handle>。",
"noAgentsInPod": {
"kicker": "此 Pod 暂无智能体",
"body": "还没有人会回复 —— 别人打开这个 Pod 时会看到这条消息。",
"action": "添加智能体"
},
"agentRoomLiveness": {
"neverConnected": "{{agentName}} 尚未连接。发送到这里的消息会等待该席位连接。",
"goneDark": "{{agentName}} 最近没有接入,回复可能需要更长时间。",
Expand Down
194 changes: 188 additions & 6 deletions frontend/src/v2/__tests__/V2ThreadDeliveryHint.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,15 +62,15 @@ const DEFAULT_AGENTS = [{
agentName: 'openclaw', instanceId: 'aria', displayName: 'Aria', status: 'active',
}];

const DeliveryHarness = ({ response, sendSpy, agents = DEFAULT_AGENTS }) => {
const DeliveryHarness = ({ response, sendSpy, agents = DEFAULT_AGENTS, podType = 'chat', podId = 'pod-1', onOpenInvite }) => {
const [messages, setMessages] = useState([]);
const sendMessage = async (...args) => {
sendSpy(...args);
setMessages((current) => [...current, response]);
return response;
};
const detail = {
pod: { _id: 'pod-1', name: 'Launch Room', type: 'chat' },
pod: { _id: podId, name: 'Launch Room', type: podType },
members: [{ _id: 'u1', username: 'alice', isBot: false }],
messages,
agents,
Expand All @@ -79,15 +79,19 @@ const DeliveryHarness = ({ response, sendSpy, agents = DEFAULT_AGENTS }) => {
error: null,
refresh: jest.fn(),
};
return <V2Thread detail={detail} />;
return <V2Thread detail={detail} onOpenInvite={onOpenInvite} />;
};

const renderHarness = (response, sendSpy = jest.fn()) => render(
const harnessElement = (response, sendSpy = jest.fn(), extra = {}) => (
<AuthContext.Provider value={authValue}>
<MemoryRouter>
<DeliveryHarness response={response} sendSpy={sendSpy} />
<DeliveryHarness response={response} sendSpy={sendSpy} {...extra} />
</MemoryRouter>
</AuthContext.Provider>,
</AuthContext.Provider>
);

const renderHarness = (response, sendSpy = jest.fn(), extra = {}) => render(
harnessElement(response, sendSpy, extra),
);

const sendDraft = (content) => {
Expand Down Expand Up @@ -206,6 +210,129 @@ describe('V2Thread agent delivery hint', () => {
expect(screen.queryByText(/No agent was notified/i)).not.toBeInTheDocument();
});

test('a pod with no agent at all says why the send went quiet, and offers the step', async () => {
const onOpenInvite = jest.fn();
renderHarness(
makeMessage('anyone there', { enqueued: 0, implicit: [], agentsInPod: 0 }),
jest.fn(),
{ agents: [], onOpenInvite },
);

sendDraft('anyone there');

const kicker = await screen.findByText('no agent in this pod');
expect(screen.getByText(/Nobody here answers yet/)).toBeInTheDocument();
// The other branch would tell the user to @mention nobody: it needs a
// handle to suggest, which is exactly why `agentsInPod > 0` gates it.
expect(screen.queryByText(/No agent was notified/i)).not.toBeInTheDocument();
// The row sits under the message it is about, not at the end of the pod.
const row = kicker.closest('.v2-chat__no-agents');
expect(row.previousElementSibling.textContent).toContain('anyone there');

fireEvent.click(screen.getByRole('button', { name: 'Add an agent' }));
// The same sheet the starter panel opens, on its agent tab.
expect(onOpenInvite).toHaveBeenCalledWith('agent');
expect(sessionStorage.getItem('v2.noAgentsHint.pod-1')).toBe('1');
});

test('the no-agent row shows at most once per pod per browser session', async () => {
const first = renderHarness(
makeMessage('first silent send', { enqueued: 0, implicit: [], agentsInPod: 0 }),
jest.fn(),
{ agents: [], onOpenInvite: jest.fn() },
);
sendDraft('first silent send');
await screen.findByText('no agent in this pod');
first.unmount();

renderHarness(
makeMessage('second silent send', { enqueued: 0, implicit: [], agentsInPod: 0 }),
jest.fn(),
{ agents: [], onOpenInvite: jest.fn() },
);
sendDraft('second silent send');
await screen.findByText('second silent send');

expect(screen.queryByText('no agent in this pod')).not.toBeInTheDocument();
});

test('the row goes as soon as an agent joins the pod', async () => {
const view = render(harnessElement(
makeMessage('anyone there', { enqueued: 0, implicit: [], agentsInPod: 0 }),
jest.fn(),
{ agents: [], onOpenInvite: jest.fn() },
));

sendDraft('anyone there');
await screen.findByText('no agent in this pod');

// The pod detail re-reads on join; no second send, so the row cannot be
// waiting for one to notice. It goes because its premise is gone.
view.rerender(harnessElement(
makeMessage('anyone there', { enqueued: 0, implicit: [], agentsInPod: 0 }),
jest.fn(),
{ agents: DEFAULT_AGENTS, onOpenInvite: jest.fn() },
));

await waitFor(() => {
expect(screen.queryByText('no agent in this pod')).not.toBeInTheDocument();
});
});

test.each([['agent-dm'], ['agent-room']])(
'stays hidden in a DM pod (%s) — its empty state already says who is missing',
async (podType) => {
renderHarness(
makeMessage('dm send', { enqueued: 0, implicit: [], agentsInPod: 0 }),
jest.fn(),
{ agents: [], podType, onOpenInvite: jest.fn() },
);

sendDraft('dm send');
await screen.findByText('dm send');

expect(screen.queryByText('no agent in this pod')).not.toBeInTheDocument();
},
);

test('stays hidden when the shell has no way to add an agent', async () => {
// The row's whole shape is the explanation plus the one step. Without the
// step it would be a sentence pointing at nothing the user can do.
renderHarness(
makeMessage('anyone there', { enqueued: 0, implicit: [], agentsInPod: 0 }),
jest.fn(),
{ agents: [] },
);

sendDraft('anyone there');
await screen.findByText('anyone there');

expect(screen.queryByText('no agent in this pod')).not.toBeInTheDocument();
});

test('an agent joining does not suppress the mention hint in the same session', async () => {
// Same pod, same session: the no-agent row first, then an agent joins, then
// a send that reaches nobody. Separate session keys, so the second hint is
// not cancelled by the first.
const view = render(harnessElement(
makeMessage('before the join', { enqueued: 0, implicit: [], agentsInPod: 0 }),
jest.fn(),
{ agents: [], onOpenInvite: jest.fn() },
));
sendDraft('before the join');
await screen.findByText('no agent in this pod');

view.rerender(harnessElement(
makeMessage('hello room', { enqueued: 0, implicit: [], agentsInPod: 1 }),
jest.fn(),
{ agents: DEFAULT_AGENTS, onOpenInvite: jest.fn() },
));
sendDraft('hello room');

const hint = await screen.findByText(/No agent was notified/);
expect(hint).toHaveTextContent('@aria');
});

test('clears the visible hint the moment an agent starts typing (#914)', async () => {
const handlers = {};
mockSocketValue = {
Expand Down Expand Up @@ -233,4 +360,59 @@ describe('V2Thread agent delivery hint', () => {
expect(screen.queryByText(/No agent was notified/i)).not.toBeInTheDocument();
});
});

test('an agent typing clears the row before the pod detail lists it', async () => {
// `agents` stays empty on purpose: the live guard cannot hide the row here,
// so only the clear in the typing handler can. That is the real #914 shape —
// an agent typing need not be in the pod detail's `agents` prop yet, and the
// row must not sit above the reply that is about to land.
const handlers = {};
mockSocketValue = {
socket: {
on: (event, fn) => { handlers[event] = fn; },
off: jest.fn(),
emit: jest.fn(),
},
connected: true,
};
renderHarness(
makeMessage('anyone there', { enqueued: 0, implicit: [], agentsInPod: 0 }),
jest.fn(),
{ agents: [], onOpenInvite: jest.fn() },
);

sendDraft('anyone there');
await screen.findByText('no agent in this pod');

act(() => {
handlers.agent_typing_start({ podId: 'pod-1', agentName: 'guide', displayName: 'Guide' });
});

await waitFor(() => {
expect(screen.queryByText('no agent in this pod')).not.toBeInTheDocument();
});
});

test('leaving the pod drops the row, and coming back does not resurrect it', async () => {
const props = { agents: [], onOpenInvite: jest.fn() };
const response = makeMessage('anyone there', { enqueued: 0, implicit: [], agentsInPod: 0 });
const view = render(harnessElement(response, jest.fn(), { ...props, podId: 'pod-1' }));

sendDraft('anyone there');
await screen.findByText('no agent in this pod');

view.rerender(harnessElement(response, jest.fn(), { ...props, podId: 'pod-2' }));
await waitFor(() => {
expect(screen.queryByText('no agent in this pod')).not.toBeInTheDocument();
});

// Back to the pod that earned it. The message is still in the transcript
// (the harness keeps it), so a state not cleared on pod change would put the
// row back under a message the user did not just send — explaining the wrong
// room, with `pod-1`'s session key already spent so no send could re-earn it.
view.rerender(harnessElement(response, jest.fn(), { ...props, podId: 'pod-1' }));
await waitFor(() => {
expect(screen.queryByText('no agent in this pod')).not.toBeInTheDocument();
});
});
});
49 changes: 49 additions & 0 deletions frontend/src/v2/__tests__/v2-layout-invariants.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,30 @@ const teamPhoneBlock = (css: string): string => {
}
};

// The `@media (max-width: 760px)` block that carries `selector`, wherever it
// sits in the sheet. A phone override is indented inside a media query, so
// `ruleBody` (which prefers a line-start selector) silently returns the desktop
// rule instead — and a guard reading the wrong rule is green while the phone
// layout is broken (TASK-140).
const mediaBlockContaining = (css: string, selector: string): string => {
const marker = '@media (max-width: 760px)';
let from = 0;
for (;;) {
const at = css.indexOf(marker, from);
if (at < 0) return '';
const open = css.indexOf('{', at);
let depth = 0;
let end = open;
for (let i = open; i < css.length; i += 1) {
if (css[i] === '{') depth += 1;
if (css[i] === '}') { depth -= 1; if (depth === 0) { end = i; break; } }
}
const block = css.slice(at, end + 1);
if (block.includes(selector)) return block;
from = end + 1;
}
};

const ruleBody = (css: string, selector: string): string => {
// Prefer a selector at the start of a CSS line. A descendant selector can
// contain the same text (`.parent .target {`) and is not the rule being
Expand Down Expand Up @@ -2032,6 +2056,31 @@ describe('v2 layout invariants (CSS rule presence)', () => {
expect(pkg.dependencies['@fontsource-variable/bricolage-grotesque']).toBeDefined();
});

test('the empty-pod row survives on phones with its one act still reachable', () => {
// A send into a pod with no agent installed is the case the delivery hint
// skips (`agentsInPod > 0`), and the row's act is the whole point of it.
expect(thread).toContain('delivery.agentsInPod === 0');
expect(thread).toContain('noAgentsHint={noAgentsInPodLive ? noAgentsHint : null}');
expect(threadMessages).toContain('className="v2-chat__no-agents"');
expect(threadMessages).toContain('podChat.noAgentsInPod.action');
expect(ruleBody(v2, '.v2-chat__no-agents')).toContain('margin: -4px 0 8px 50px');
// `.v2-chat__messages > *` sets width: 100%, so the 50px indent must come
// OUT of the width. Without this the row overruns the pane by 50px and the
// full-width phone act and the body line are cut (ux-lead's 390 shot).
expect(ruleBody(v2, '.v2-chat__messages > *')).toContain('width: 100%');
expect(ruleBody(v2, '.v2-chat__no-agents')).toContain('width: calc(100% - 50px)');
expect(ruleBody(v2, '.v2-chat__no-agents-kicker')).toContain('font-size: 11px');
expect(ruleBody(v2, '.v2-chat__no-agents-kicker')).toContain('text-transform: lowercase');
expect(ruleBody(v2, '.v2-chat__no-agents-body')).toContain('color: var(--v2-text-secondary)');
expect(ruleBody(v2, '.v2-root button.v2-chat__no-agents-action')).toContain('min-height: 32px');
const phone = mediaBlockContaining(v2, 'button.v2-chat__no-agents-action');
expect(phone).toContain('min-height: 44px');
expect(phone).toContain('width: 100%');
// The override has to beat the base MINIMUM. A `height: 44px` here would
// lose to `min-height: 32px` and silently render 32 on a phone.
expect(phone).not.toMatch(/[^-]height: 44px/);
});

test('pod focus keeps one bounded panel above the existing board and stays usable on phones', () => {
expect(podBoard).toContain('className="v2-board__focus"');
expect(podBoard).toContain('v2-board__focus-editor');
Expand Down
Loading
Loading