From 087a3427740ab044b8f0a1a9b9418b23aa257134 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Wed, 23 Sep 2026 02:54:49 -0700 Subject: [PATCH 1/2] feat(v2): a pod with no agent says so, and offers the one step MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lily's smoke: created a team pod with no agent, posted once, got nothing, created a second pod with an agent. The existing delivery hint ("No agent was notified — @mention one") is gated on agentsInPod > 0 because it needs a handle to suggest, so this exact pod is the one it skips — and the starter panel's "Add an agent" act is already gone once a message exists. When a send returns agentsInPod === 0 in a non-DM pod, a system row renders in the same slot under the sent message: mono-11 kicker "no agent in this pod", "Nobody here answers yet — people will see this when they open the pod.", and one bordered act, Add an agent (32 desktop / full-width 44 at <=760) opening the starter panel's own sheet on its agent tab. Once per pod per browser session on its own key, so it and the mention hint cannot cancel each other — both can happen in one pod in one session. It goes when an agent joins, not on the next send: gated on the live pod detail. Excluded deliberately: DM pods (an agent by construction; their empty states name who is missing) and pods with no invite surface (the row is explanation plus the one step, so half of it is worse than none). The row's 50px indent has to come out of its width: the column's `> *` rule sets width: 100%, so without `width: calc(100% - 50px)` it overruns the pane by exactly 50px (ux-lead's 390 gate: the act ran 64->426 and the body line was cut). Precedent in the same file — `.v2-thread-block` carries the identical pair with Sam's 2026-08-24 note that indent + width must sum to 100%. Tests: V2ThreadDeliveryHint 15/15 (7 new), 169/169 across 17 thread/shell suites, layout invariants 109/109 (1 new). Nine mutations, each red on the expected test — including the phone override as `height` instead of `min-height` (would lose to the base 32 minimum, TASK-140) and removing the width, which reproduces ux-lead's 390 overflow as a guard failure. --- frontend/src/i18n/locales/en.json | 5 + frontend/src/i18n/locales/zh-CN.json | 5 + .../__tests__/V2ThreadDeliveryHint.test.tsx | 139 +++++++++++++++++- .../v2/__tests__/v2-layout-invariants.test.ts | 49 ++++++ frontend/src/v2/components/V2Thread.tsx | 46 +++++- .../src/v2/components/V2ThreadMessages.tsx | 21 +++ frontend/src/v2/v2.css | 60 ++++++++ 7 files changed, 318 insertions(+), 7 deletions(-) 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..470ec73c9 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', 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: 'pod-1', 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 = { 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. */ From 2278186c05151a793ff598d22812c383852d5776 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Wed, 23 Sep 2026 03:16:32 -0700 Subject: [PATCH 2/2] test(v2): pin the two clears the empty-pod row depends on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sprint-review's gate on 09358ff0 passed but measured two silent-deletion paths, one test each: - removing `setNoAgentsHint(null)` from the typing handler left all 15 green. This is the #914 shape: the row must not sit above a reply that is about to land, and a typing agent need not be in the pod detail's `agents` prop yet, so the live guard cannot mask it — the probe shows the row staying put. The new test keeps `agents: []` for exactly that reason, so only the clear can pass it. - removing the clear from the pod-change effect left all green too: leave the pod and come back, and the row is resurrected under a message the user did not just send, with pod-1's session key already spent so no send could re-earn it. Both are behaviour already implemented and documented in comments; neither was defended. Each mutation reds exactly one test — the new one — so neither is an equivalent mutant. V2ThreadDeliveryHint 17/17. --- .../__tests__/V2ThreadDeliveryHint.test.tsx | 59 ++++++++++++++++++- 1 file changed, 57 insertions(+), 2 deletions(-) diff --git a/frontend/src/v2/__tests__/V2ThreadDeliveryHint.test.tsx b/frontend/src/v2/__tests__/V2ThreadDeliveryHint.test.tsx index 470ec73c9..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, podType = 'chat', onOpenInvite }) => { +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, podType = return response; }; const detail = { - pod: { _id: 'pod-1', name: 'Launch Room', type: podType }, + pod: { _id: podId, name: 'Launch Room', type: podType }, members: [{ _id: 'u1', username: 'alice', isBot: false }], messages, agents, @@ -360,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(); + }); + }); });