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. */