diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json
index 007585a7c..7d4f00f8e 100644
--- a/frontend/src/i18n/locales/en.json
+++ b/frontend/src/i18n/locales/en.json
@@ -412,6 +412,11 @@
"cancelReply": "Cancel reply",
"replyingTo": "Replying to {{author}}:",
"deliveryHint": "No agent was notified — @mention one to get a reply. Try @{{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.",
diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json
index 2a4c13ed1..ec4e433e3 100644
--- a/frontend/src/i18n/locales/zh-CN.json
+++ b/frontend/src/i18n/locales/zh-CN.json
@@ -412,6 +412,11 @@
"cancelReply": "取消回复",
"replyingTo": "回复 {{author}}:",
"deliveryHint": "尚未通知任何智能体。@提及一个智能体即可让它回复,例如 @{{handle}}。",
+ "noAgentsInPod": {
+ "kicker": "此 Pod 暂无智能体",
+ "body": "还没有人会回复 —— 别人打开这个 Pod 时会看到这条消息。",
+ "action": "添加智能体"
+ },
"agentRoomLiveness": {
"neverConnected": "{{agentName}} 尚未连接。发送到这里的消息会等待该席位连接。",
"goneDark": "{{agentName}} 最近没有接入,回复可能需要更长时间。",
diff --git a/frontend/src/v2/__tests__/V2ThreadDeliveryHint.test.tsx b/frontend/src/v2/__tests__/V2ThreadDeliveryHint.test.tsx
index 1965de339..0767c2a22 100644
--- a/frontend/src/v2/__tests__/V2ThreadDeliveryHint.test.tsx
+++ b/frontend/src/v2/__tests__/V2ThreadDeliveryHint.test.tsx
@@ -62,7 +62,7 @@ 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);
@@ -70,7 +70,7 @@ const DeliveryHarness = ({ response, sendSpy, agents = DEFAULT_AGENTS }) => {
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,
@@ -79,15 +79,19 @@ const DeliveryHarness = ({ response, sendSpy, agents = DEFAULT_AGENTS }) => {
error: null,
refresh: jest.fn(),
};
- return ;
+ return ;
};
-const renderHarness = (response, sendSpy = jest.fn()) => render(
+const harnessElement = (response, sendSpy = jest.fn(), extra = {}) => (
-
+
- ,
+
+);
+
+const renderHarness = (response, sendSpy = jest.fn(), extra = {}) => render(
+ harnessElement(response, sendSpy, extra),
);
const sendDraft = (content) => {
@@ -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 = {
@@ -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();
+ });
+ });
});
diff --git a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts
index ed70fc27c..8357d080c 100644
--- a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts
+++ b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts
@@ -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
@@ -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');
diff --git a/frontend/src/v2/components/V2Thread.tsx b/frontend/src/v2/components/V2Thread.tsx
index d0ec0d46f..c19991ef3 100644
--- a/frontend/src/v2/components/V2Thread.tsx
+++ b/frontend/src/v2/components/V2Thread.tsx
@@ -35,6 +35,10 @@ import {
} from '../utils/threadAgentIdentity';
const AGENT_DELIVERY_HINT_KEY = 'v2.agentDeliveryHint';
+// Same slot, different fact, so a pod can show both over a session: the hint
+// above needs an agent to name, and this row exists precisely because there is
+// none. Sharing one key would let either suppress the other.
+const NO_AGENTS_HINT_KEY = 'v2.noAgentsHint';
const JUST_CREATED_POD_KEY = 'v2.justCreated';
const AGENT_INVITE_TAB: V2InviteTab = 'agent';
// A sent direct message is durable in the room, but it is not a reply. Give
@@ -240,6 +244,10 @@ const V2Thread: React.FC = ({ detail, firstRunVisible = false, in
messageId: string;
mentionHandle: string;
} | null>(null);
+ // A send into a pod with no installed agent: the delivery hint below is
+ // deliberately gated on `agentsInPod > 0`, which is exactly the case this is
+ // for. Nothing was notified because nothing is here — say so, once.
+ const [noAgentsHint, setNoAgentsHint] = useState<{ messageId: string } | null>(null);
const [awaitingAgentReply, setAwaitingAgentReply] = useState(null);
const deliveryHintShownPodsRef = useRef>(new Set());
const messagesEndRef = useRef(null);
@@ -535,6 +543,9 @@ const V2Thread: React.FC = ({ detail, firstRunVisible = false, in
useEffect(() => {
setAgentDeliveryHint(null);
+ // The no-agent row belongs to the pod it was earned in: carrying it into
+ // another pod would explain the wrong room.
+ setNoAgentsHint(null);
}, [pod?._id]);
useEffect(() => {
@@ -1132,8 +1143,10 @@ const V2Thread: React.FC = ({ detail, firstRunVisible = false, in
const agentName = payload.agentName || payload.username;
if (!agentName) return;
// An agent typing in this pod falsifies "No agent was notified" — the
- // hint must not sit above a landing reply (#914).
+ // hint must not sit above a landing reply (#914). It also means an agent
+ // exists, which is the one thing the no-agent row asserts the absence of.
setAgentDeliveryHint(null);
+ setNoAgentsHint(null);
const key = keyFor({ agentName, instanceId: payload.instanceId });
scheduleAutoStop(key);
setTypingAgents((prev) => {
@@ -1224,6 +1237,10 @@ const V2Thread: React.FC = ({ detail, firstRunVisible = false, in
const isAgentDm = pod.type === 'agent-dm';
const isAgentRoom = pod.type === 'agent-room';
const isReadOnly = isAgentDm && !isPodMember;
+ // Live counterpart of the server's `agentDelivery.agentsInPod` (active
+ // installations for this pod): the no-agent row must disappear the moment an
+ // agent joins, without waiting for a second send to re-measure.
+ const noAgentsInPodLive = !(agents || []).some((agent) => agent.status === 'active');
// Bot-bot agent-dm — used to choose the "X and Y haven't talked yet" empty
// state and to phrase the read-only banner appropriately.
@@ -1333,6 +1350,31 @@ const V2Thread: React.FC = ({ detail, firstRunVisible = false, in
}
setAgentDeliveryHint({ messageId: created.id, mentionHandle });
}
+ } else if (
+ delivery
+ && delivery.agentsInPod === 0
+ // DM pods are excluded: both types have an agent by construction, and
+ // their empty states already explain who is missing.
+ && !isAgentDm
+ && !isAgentRoom
+ && onOpenInvite
+ ) {
+ const storageKey = `${NO_AGENTS_HINT_KEY}.${pod._id}`;
+ let alreadyShown = deliveryHintShownPodsRef.current.has(storageKey);
+ try {
+ alreadyShown = alreadyShown || sessionStorage.getItem(storageKey) === '1';
+ } catch {
+ // In-memory guard still prevents repeat rows in this mount.
+ }
+ if (!alreadyShown) {
+ deliveryHintShownPodsRef.current.add(storageKey);
+ try {
+ sessionStorage.setItem(storageKey, '1');
+ } catch {
+ // sessionStorage unavailable; the in-memory guard still works.
+ }
+ setNoAgentsHint({ messageId: created.id });
+ }
}
setDraft('');
setReplyTarget(null);
@@ -1583,6 +1625,8 @@ const V2Thread: React.FC = ({ detail, firstRunVisible = false, in
) : undefined}
agentDeliveryHint={agentDeliveryHint}
+ noAgentsHint={noAgentsInPodLive ? noAgentsHint : null}
+ onAddAgent={onOpenInvite ? () => onOpenInvite(AGENT_INVITE_TAB) : undefined}
messagesContainerRef={messagesContainerRef}
messagesEndRef={messagesEndRef}
/>
diff --git a/frontend/src/v2/components/V2ThreadMessages.tsx b/frontend/src/v2/components/V2ThreadMessages.tsx
index cdc04973b..a241c714c 100644
--- a/frontend/src/v2/components/V2ThreadMessages.tsx
+++ b/frontend/src/v2/components/V2ThreadMessages.tsx
@@ -52,6 +52,10 @@ interface V2ThreadMessagesProps {
starterPanel?: React.ReactNode;
emptyState?: React.ReactNode;
agentDeliveryHint?: { messageId: string; mentionHandle: string } | null;
+ // A pod with no installed agent answers nothing by construction, so there is
+ // no handle to suggest — a different row, in the same slot.
+ noAgentsHint?: { messageId: string } | null;
+ onAddAgent?: () => void;
messagesContainerRef: React.RefObject;
messagesEndRef: React.RefObject;
}
@@ -135,6 +139,8 @@ const V2ThreadMessages: React.FC = ({
starterPanel,
emptyState,
agentDeliveryHint,
+ noAgentsHint,
+ onAddAgent,
messagesContainerRef,
messagesEndRef,
}) => {
@@ -278,6 +284,21 @@ const V2ThreadMessages: React.FC = ({
/>
)}
+ {noAgentsHint?.messageId === message.id && (
+
+
{t('podChat.noAgentsInPod.kicker')}
+
{t('podChat.noAgentsInPod.body')}
+ {onAddAgent && (
+
+ )}
+
+ )}
);
};
diff --git a/frontend/src/v2/v2.css b/frontend/src/v2/v2.css
index 41676e4a9..40d3246e5 100644
--- a/frontend/src/v2/v2.css
+++ b/frontend/src/v2/v2.css
@@ -1831,6 +1831,66 @@ body.modern-ui.v2-canvas {
font-weight: 600;
}
+/* The delivery hint above requires an agent to name (`agentsInPod > 0`), which
+ leaves out the pod where nothing can answer at all — a team pod with no agent
+ installed. This row sits in the same slot under the sent message, says why
+ the send went quiet, and carries the one step that changes it. Ink and tint
+ only: nothing here succeeded, and no accent or toast should say otherwise. */
+.v2-chat__no-agents {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 6px;
+ /* `.v2-chat__messages > *` sets width: 100%, so the 50px indent below has to
+ be subtracted or the row overruns the pane by exactly 50px: at 390 the
+ full-width act ran 64→426 and the body line was cut (ux-lead's shot,
+ 2026-09-23). Same rule as `.v2-thread-block` below — indent + width must
+ sum to 100% (Sam, 2026-08-24, the same bug one indent over). */
+ width: calc(100% - 50px);
+ margin: -4px 0 8px 50px;
+}
+
+.v2-chat__no-agents-kicker {
+ color: var(--v2-text-tertiary);
+ font-family: var(--v2-font-mono);
+ font-size: 11px;
+ font-weight: 500;
+ line-height: 16px;
+ text-transform: lowercase;
+}
+
+.v2-chat__no-agents-body {
+ color: var(--v2-text-secondary);
+ font-size: 12px;
+ line-height: 1.4;
+}
+
+.v2-root button.v2-chat__no-agents-action {
+ min-height: 32px;
+ padding: 0 12px;
+ border: 1px solid var(--v2-border);
+ border-radius: var(--v2-radius);
+ background: var(--v2-surface);
+ color: var(--v2-text-primary);
+ font-size: 12px;
+ font-weight: 600;
+ transition: background 80ms ease, border-color 80ms ease;
+}
+
+.v2-root button.v2-chat__no-agents-action:hover {
+ background: var(--v2-surface-hover);
+}
+
+@media (max-width: 760px) {
+ /* The row's single act is a touch target on a phone: full width of the row,
+ 44 tall. This must override the MINIMUM, not a height — a `height` here
+ would lose to the base rule's own min-height and render 32 (TASK-140). */
+ .v2-root button.v2-chat__no-agents-action {
+ width: 100%;
+ min-height: 44px;
+ }
+}
+
/* First-run is a shell-level dialog. The overlay separates the one-time setup
path from pod content, while the dialog itself owns overflow on short mobile
viewports so every step and the dismissal control stay reachable. */