diff --git a/backend/__tests__/service/attentionQueue.counts.test.js b/backend/__tests__/service/attentionQueue.counts.test.js new file mode 100644 index 000000000..4013e369a --- /dev/null +++ b/backend/__tests__/service/attentionQueue.counts.test.js @@ -0,0 +1,50 @@ +const mongoose = require('mongoose'); +const Pod = require('../../models/Pod'); +const AttentionItem = require('../../models/AttentionItem'); +const service = require('../../services/attentionItemService'); +const { setupMongoDb, closeMongoDb, clearMongoDb } = require('../utils/testUtils'); + +describe('uncapped attention counts — persisted query and membership', () => { + beforeAll(setupMongoDb); + afterAll(closeMongoDb); + afterEach(clearMongoDb); + + it('counts beyond 80, excludes revoked membership and other recipients, and recounts after acknowledgement', async () => { + const recipient = new mongoose.Types.ObjectId(); + const other = new mongoose.Types.ObjectId(); + const [busy, omitted, revoked] = await Pod.create([ + { name: 'Busy', type: 'team', createdBy: recipient, members: [recipient] }, + { name: 'Outside display cap', type: 'team', createdBy: other, members: [recipient] }, + { name: 'Revoked', type: 'team', createdBy: other, members: [other] }, + ]); + const make = (id, pod, kind = 'mention', who = recipient, status = 'open') => ({ + recipientUserId: who, podId: pod._id, kind, status, + source: { type: kind === 'mention' ? 'message' : 'approval', id }, title: id, + createdAt: new Date('2026-09-01T00:00:00Z'), + }); + await AttentionItem.insertMany([ + ...Array.from({ length: 90 }, (_, i) => make(`mention-${i}`, busy)), + ...Array.from({ length: 4 }, (_, i) => make(`approval-${i}`, busy, 'approval')), + { ...make('oldest', omitted), createdAt: new Date('2020-01-01') }, + make('revoked', revoked), make('other-user', busy, 'mention', other), + make('closed', busy, 'mention', recipient, 'resolved'), + ]); + const queue = await service.getOpenQueue(recipient); + expect(queue.count).toBe(95); + expect(queue.countsByPod).toEqual({ [busy.id]: 94, [omitted.id]: 1 }); + expect(queue.items).toHaveLength(12); + expect(queue.items.filter((item) => item.kind === 'mention')).toHaveLength(8); + expect(queue.items.some((item) => item.podId === omitted.id)).toBe(false); + + const old = await AttentionItem.findOne({ 'source.id': 'oldest' }); + expect(await service.acknowledgeMention(other, old.id)).toMatchObject({ success: false }); + expect(await service.acknowledgeMention(recipient, old.id)).toMatchObject({ success: true }); + const next = await service.getOpenQueue(recipient); + expect(next.count).toBe(94); + expect(next.countsByPod).toEqual({ [busy.id]: 94 }); + }); + + it('returns an authoritative empty shape for invalid recipients', async () => { + expect(await service.getOpenQueue('invalid')).toEqual({ items: [], count: 0, countsByPod: {}, composePodId: null }); + }); +}); diff --git a/backend/__tests__/unit/services/attentionItemService.test.js b/backend/__tests__/unit/services/attentionItemService.test.js index e155e28ad..d6d3098ec 100644 --- a/backend/__tests__/unit/services/attentionItemService.test.js +++ b/backend/__tests__/unit/services/attentionItemService.test.js @@ -112,10 +112,10 @@ describe('attentionItemService', () => { }); it('returns only rows whose recipient is still a member and resolves by recipient-owned id', async () => { - mockFind.mockReturnValue({ sort: () => ({ limit: () => ({ lean: async () => [ + mockFind.mockReturnValue({ sort: () => ({ lean: async () => [ { _id: 'attention-1', recipientUserId: '507f191e810c19729de860ea', podId: 'pod-1', kind: 'mention', source: { type: 'message', id: '41' }, title: 'Mention', createdAt: new Date() }, { _id: 'attention-2', recipientUserId: '507f191e810c19729de860ea', podId: 'pod-2', kind: 'approval', source: { type: 'approval', id: 'a-1' }, title: 'Old access', createdAt: new Date() }, - ] }) }) }); + ] }) }); mockPodFind.mockReturnValue(chain([ { _id: 'pod-1', name: 'Current', createdBy: '507f191e810c19729de860ea', members: [] }, { _id: 'pod-2', name: 'Removed', createdBy: 'someone-else', members: [] }, diff --git a/backend/services/attentionItemService.ts b/backend/services/attentionItemService.ts index d639f9182..e0666ba32 100644 --- a/backend/services/attentionItemService.ts +++ b/backend/services/attentionItemService.ts @@ -337,12 +337,13 @@ export const resolveMany = async (sourceType: SourceType, sourceIds: unknown[]): } }; -export const getOpenQueue = async (recipientUserId: unknown): Promise<{ items: any[]; count: number; composePodId: string | null }> => { +export const getOpenQueue = async (recipientUserId: unknown): Promise<{ items: any[]; count: number; countsByPod: Record; composePodId: string | null }> => { // Route callers carry a real Mongo id. Returning an empty queue for a bad // value keeps malformed/read-only callers from turning a cast error into a // 500 and makes the authorization boundary explicit. - if (!/^[a-f\d]{24}$/i.test(String(recipientUserId))) return { items: [], count: 0, composePodId: null }; - const rows = await AttentionItem.find({ recipientUserId, status: 'open' }).sort({ createdAt: -1 }).limit(80).lean(); + if (!/^[a-f\d]{24}$/i.test(String(recipientUserId))) return { items: [], count: 0, countsByPod: {}, composePodId: null }; + // Counts include every accessible open item; only the rendered cards are capped. + const rows = await AttentionItem.find({ recipientUserId, status: 'open' }).sort({ createdAt: -1 }).lean(); const podIds = [...new Set(rows.map((row: any) => String(row.podId)))]; const pods = await Pod.find({ _id: { $in: podIds } }).select('_id name createdBy members').lean(); const allowed = new Map(pods.filter((pod: any) => isCurrentMember(pod, recipientUserId)).map((pod: any) => [String(pod._id), pod])); @@ -363,7 +364,12 @@ export const getOpenQueue = async (recipientUserId: unknown): Promise<{ items: a messageId: row.messageId, threadRootId: row.threadRootId, options: row.options || [], createdAt: row.createdAt, }); } - return { items: picked, count: valid.length, composePodId: picked.find((row) => row.kind === 'mention')?.podId || null }; + const countsByPod = valid.reduce((counts: Record, row: any) => { + const podId = String(row.podId); + counts[podId] = (counts[podId] || 0) + 1; + return counts; + }, {}); + return { items: picked, count: valid.length, countsByPod, composePodId: picked.find((row) => row.kind === 'mention')?.podId || null }; }; export const acknowledgeMention = async (recipientUserId: unknown, attentionItemId: string): Promise<{ success: boolean; error?: string }> => { diff --git a/frontend/src/v2/__tests__/V2ActivityPage.test.tsx b/frontend/src/v2/__tests__/V2ActivityPage.test.tsx index f3f7dd61c..6f37cd470 100644 --- a/frontend/src/v2/__tests__/V2ActivityPage.test.tsx +++ b/frontend/src/v2/__tests__/V2ActivityPage.test.tsx @@ -21,8 +21,7 @@ const CurrentPath = () => { return
{location.pathname}{location.search}
; }; -// The queue now arrives from /decision-queue (TASK-083) — recap.needsYou is -// only the degrade path when that endpoint fails. +// Only the queue endpoint supplies attention; recap is not a fallback. const decisionQueue = { items: [ { @@ -38,6 +37,7 @@ const decisionQueue = { }, ], count: 2, + countsByPod: { 'pod-1': 2 }, composePodId: 'pod-1', }; @@ -73,13 +73,8 @@ describe('V2ActivityPage', () => { beforeEach(async () => { jest.clearAllMocks(); - // Default: the decision-queue endpoint FAILS, exercising the designed - // degrade path (fall back to recap.needsYou) — which also keeps the - // pre-existing tests' order-based mockResolvedValueOnce chains valid, - // since their Once values feed the recap call and this implementation - // catches the queue call. The first test overrides with real items. mockGet.mockImplementation((url: string) => { - if (url === '/api/activity/decision-queue') return Promise.reject(new Error('queue down')); + if (url === '/api/activity/decision-queue') return Promise.resolve({ data: decisionQueue }); return Promise.resolve({ data: recap }); }); await act(async () => { await i18n.changeLanguage('en'); }); @@ -131,15 +126,16 @@ describe('V2ActivityPage', () => { }); test('acknowledges a mention explicitly instead of treating a feed read as acknowledgement', async () => { - mockGet - .mockResolvedValueOnce({ data: recap }) - .mockResolvedValue({ data: { ...recap, needsYou: [] } }); + let reads = 0; + mockGet.mockImplementation((url: string) => Promise.resolve({ data: url === '/api/activity/decision-queue' + ? (++reads === 1 ? { ...decisionQueue, items: [decisionQueue.items[0]], count: 1 } : { items: [], count: 0, countsByPod: {} }) + : recap })); mockPost.mockResolvedValue({ data: { success: true } }); renderPage(); fireEvent.click(await screen.findByRole('button', { name: 'Acknowledge' })); await waitFor(() => expect(mockPost).toHaveBeenCalledWith( - '/api/activity/mention-1/acknowledge', + '/api/activity/attention-1/acknowledge', {}, expect.objectContaining({ headers: expect.any(Object) }), )); @@ -147,7 +143,8 @@ describe('V2ActivityPage', () => { }); test('keeps an empty Needs you state honest', async () => { - mockGet.mockResolvedValue({ data: { ...recap, needsYou: [] } }); + mockGet.mockImplementation((url: string) => Promise.resolve({ data: url === '/api/activity/decision-queue' + ? { items: [], count: 0, countsByPod: {} } : recap })); renderPage(); expect(await screen.findByText('Nothing is waiting on you')).toBeInTheDocument(); @@ -155,7 +152,8 @@ describe('V2ActivityPage', () => { }); test('turns a truly empty workspace into the three factual onboarding rows', async () => { - mockGet.mockResolvedValue({ data: { ...recap, needsYou: [], agents: [], board: [] } }); + mockGet.mockImplementation((url: string) => Promise.resolve({ data: url === '/api/activity/decision-queue' + ? { items: [], count: 0, countsByPod: {} } : { ...recap, needsYou: [], agents: [], board: [] } })); const onGuide = jest.fn(); window.addEventListener(FIRST_RUN_REOPEN_EVENT, onGuide); renderPage(); @@ -297,4 +295,20 @@ describe('V2ActivityPage', () => { )); expect(composer).toHaveValue(''); }); + + test('renders the uncapped count, not the displayed card count', async () => { + mockGet.mockImplementation((url: string) => Promise.resolve({ data: url === '/api/activity/decision-queue' + ? { ...decisionQueue, count: 91, countsByPod: { 'pod-1': 91 } } : recap })); + renderPage(); + expect(await screen.findByLabelText('91 waiting on you')).toHaveTextContent('91'); + }); + + test('does not substitute recap attention or claim empty on queue failure', async () => { + mockGet.mockImplementation((url: string) => url === '/api/activity/decision-queue' + ? Promise.reject(new Error('queue down')) : Promise.resolve({ data: recap })); + renderPage(); + expect(await screen.findByRole('status')).toBeInTheDocument(); + expect(screen.queryByText('Review requested')).not.toBeInTheDocument(); + expect(screen.queryByText('Nothing is waiting on you')).not.toBeInTheDocument(); + }); }); diff --git a/frontend/src/v2/__tests__/V2AgentBYOListenVerify.test.tsx b/frontend/src/v2/__tests__/V2AgentBYOListenVerify.test.tsx index b0d98ac5d..8144b0456 100644 --- a/frontend/src/v2/__tests__/V2AgentBYOListenVerify.test.tsx +++ b/frontend/src/v2/__tests__/V2AgentBYOListenVerify.test.tsx @@ -11,6 +11,8 @@ import { render, screen, fireEvent, waitFor, act } from '@testing-library/react' import { MemoryRouter } from 'react-router-dom'; import V2AgentBYO from '../components/V2AgentBYO'; import { AuthContext } from '../../context/AuthContext'; +// This suite owns the BYO request sequence, not the rail's attention query. +jest.mock('../components/V2NavRail', () => () => null); jest.mock('axios', () => { const mock = { diff --git a/frontend/src/v2/__tests__/V2AgentBYOMemory.test.tsx b/frontend/src/v2/__tests__/V2AgentBYOMemory.test.tsx index 58d9a7942..0ceda9e50 100644 --- a/frontend/src/v2/__tests__/V2AgentBYOMemory.test.tsx +++ b/frontend/src/v2/__tests__/V2AgentBYOMemory.test.tsx @@ -8,6 +8,8 @@ import { render, screen, fireEvent, waitFor, act } from '@testing-library/react' import { MemoryRouter } from 'react-router-dom'; import V2AgentBYO from '../components/V2AgentBYO'; import { AuthContext } from '../../context/AuthContext'; +// This suite owns the BYO request sequence, not the rail's attention query. +jest.mock('../components/V2NavRail', () => () => null); jest.mock('axios', () => { const mock = { diff --git a/frontend/src/v2/__tests__/V2AgentBYOPodPicker.test.tsx b/frontend/src/v2/__tests__/V2AgentBYOPodPicker.test.tsx index 49ca2d305..a2951a4d7 100644 --- a/frontend/src/v2/__tests__/V2AgentBYOPodPicker.test.tsx +++ b/frontend/src/v2/__tests__/V2AgentBYOPodPicker.test.tsx @@ -10,6 +10,8 @@ import { render, screen, waitFor } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; import V2AgentBYO from '../components/V2AgentBYO'; import { AuthContext } from '../../context/AuthContext'; +// This suite owns the BYO request sequence, not the rail's attention query. +jest.mock('../components/V2NavRail', () => () => null); jest.mock('axios', () => { const mock = { diff --git a/frontend/src/v2/__tests__/V2CommunityNav.test.tsx b/frontend/src/v2/__tests__/V2CommunityNav.test.tsx index 6e2973941..4edec5835 100644 --- a/frontend/src/v2/__tests__/V2CommunityNav.test.tsx +++ b/frontend/src/v2/__tests__/V2CommunityNav.test.tsx @@ -60,6 +60,12 @@ describe('Community navigation', () => { await i18nReady; }); + test('rail shows the full queue count, not a capped card count or 99+', async () => { + mockAxiosGet.mockResolvedValue({ data: { items: [], count: 105, countsByPod: { hidden: 105 } } }); + renderRail(); + expect(await screen.findByLabelText('105 waiting on you')).toHaveTextContent('105'); + }); + beforeEach(() => { process.env.REACT_APP_COMMUNITY_POD_ID = COMMUNITY_POD_ID; process.env.REACT_APP_COMMUNITY_INVITE_TOKEN = COMMUNITY_INVITE_TOKEN; diff --git a/frontend/src/v2/__tests__/V2Inspector.test.tsx b/frontend/src/v2/__tests__/V2Inspector.test.tsx index 12539080b..ff5156289 100644 --- a/frontend/src/v2/__tests__/V2Inspector.test.tsx +++ b/frontend/src/v2/__tests__/V2Inspector.test.tsx @@ -35,6 +35,7 @@ const renderInspector = (props: Partial { test('does not render a stale attention item from another pod', async () => { mockGet.mockResolvedValue({ tasks: [] }); - renderInspector({ attentionItems: [{ id: 'other', kind: 'decision', title: 'Other pod', podId: 'pod-2' }] }); + renderInspector({ attentionCount: 0, attentionItems: [{ id: 'other', kind: 'decision', title: 'Other pod', podId: 'pod-2' }] }); await waitFor(() => expect(screen.getByText('Nothing. Wren is working.')).toBeInTheDocument()); expect(screen.queryByText('Other pod')).not.toBeInTheDocument(); }); test('uses the settled-workspace empty copy when no agent is working', async () => { mockGet.mockImplementation(() => Promise.resolve({ items: [], tasks: [] })); - renderInspector({ detail: { ...detail, agents: [] } as any, attentionItems: [] }); + renderInspector({ detail: { ...detail, agents: [] } as any, attentionCount: 0, attentionItems: [] }); expect(await screen.findByText('Nothing open.')).toBeInTheDocument(); }); + + test('does not claim nothing open when this pod falls outside the display cap', async () => { + renderInspector({ attentionCount: 83, attentionItems: [] }); + fireEvent.click(await screen.findByRole('button', { name: '83 waiting on you' })); + expect(mockNavigate).toHaveBeenCalledWith('/v2/activity'); + expect(screen.queryByText(/Nothing/)).not.toBeInTheDocument(); + }); }); diff --git a/frontend/src/v2/__tests__/V2PodsSidebar.community.test.tsx b/frontend/src/v2/__tests__/V2PodsSidebar.community.test.tsx index 18b5006b9..52c47e01b 100644 --- a/frontend/src/v2/__tests__/V2PodsSidebar.community.test.tsx +++ b/frontend/src/v2/__tests__/V2PodsSidebar.community.test.tsx @@ -47,7 +47,7 @@ const renderSidebar = (pods, selectedPodId = 'sharpen') => render( { test('uses the decision queue count only on the selected room and retains no legacy controls', async () => { renderSidebar([pod('sharpen', 'Sharpen', 'team', [human('me'), human('other')])]); - expect(await screen.findByLabelText('1 needs you')).toHaveTextContent('1'); + expect(await screen.findByLabelText('91 needs you')).toHaveTextContent('91'); expect(screen.queryByPlaceholderText('Search pods...')).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'All' })).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'Community' })).not.toBeInTheDocument(); diff --git a/frontend/src/v2/__tests__/useV2PodAttention.test.tsx b/frontend/src/v2/__tests__/useV2PodAttention.test.tsx new file mode 100644 index 000000000..d8b44c1c8 --- /dev/null +++ b/frontend/src/v2/__tests__/useV2PodAttention.test.tsx @@ -0,0 +1,24 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import { useV2PodAttention, notifyAttentionChanged } from '../hooks/useV2PodAttention'; + +const mockGet = jest.fn(); +jest.mock('../hooks/useV2Api', () => ({ useV2Api: () => ({ get: mockGet }) })); + +test('uses uncapped endpoint totals even when a pod has no displayed cards, and refreshes after resolution', async () => { + mockGet.mockResolvedValue({ items: [], count: 91, countsByPod: { hidden: 91 } }); + const { result } = renderHook(() => useV2PodAttention()); + await waitFor(() => expect(result.current.count).toBe(91)); + expect(result.current.countByPod).toEqual({ hidden: 91 }); + mockGet.mockResolvedValue({ items: [], count: 0, countsByPod: {} }); + act(() => { notifyAttentionChanged(); }); + await waitFor(() => expect(result.current.count).toBe(0)); + expect(result.current.countByPod).toEqual({}); +}); + +test('an unavailable queue is unknown, not an invented zero', async () => { + mockGet.mockRejectedValue(new Error('offline')); + const { result } = renderHook(() => useV2PodAttention()); + await act(async () => {}); + expect(result.current.count).toBeNull(); + expect(result.current.items).toEqual([]); +}); diff --git a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts index d69a9e1f2..e6daaeec7 100644 --- a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts +++ b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts @@ -214,6 +214,9 @@ describe('v2 layout invariants (CSS rule presence)', () => { expect(podAttention).toContain("'/api/activity/decision-queue'"); expect(v2Layout).toContain('useV2PodAttention()'); expect(v2Layout).toContain('attentionItems={attention.items}'); + expect(v2Layout).toContain('attentionCountByPod={attention.countByPod}'); + expect(v2Layout).toContain('needsYouCount={attention.count}'); + expect(v2Layout).toContain('attentionCount={attention.count === null ? null : (attention.countByPod[selectedPodId] || 0)}'); expect(v2Layout).toContain('needsYouCount={selectedPodId ? (attention.countByPod[selectedPodId] || 0) : 0}'); expect(podsSidebar).toContain('attentionCountByPod'); expect(podsSidebar).toContain('selected && attentionCount > 0'); diff --git a/frontend/src/v2/components/V2ActivityPage.tsx b/frontend/src/v2/components/V2ActivityPage.tsx index 4447d6772..2b03ccb3b 100644 --- a/frontend/src/v2/components/V2ActivityPage.tsx +++ b/frontend/src/v2/components/V2ActivityPage.tsx @@ -4,6 +4,7 @@ import { useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import V2Avatar from './V2Avatar'; import { requestFirstRunGuide } from '../firstRunGuide'; +import { ATTENTION_CHANGED, notifyAttentionChanged } from '../hooks/useV2PodAttention'; type ActivityWindow = 'today' | '7d'; @@ -92,6 +93,18 @@ const V2ActivityPage: React.FC = () => { const [composeError, setComposeError] = useState(null); const [queue, setQueue] = useState([]); + const [queueCount, setQueueCount] = useState(null); + const [queueFailed, setQueueFailed] = useState(false); + + useEffect(() => { + const refresh = () => setReloadKey((value) => value + 1); + globalThis.window.addEventListener(ATTENTION_CHANGED, refresh); + globalThis.window.addEventListener('focus', refresh); + return () => { + globalThis.window.removeEventListener(ATTENTION_CHANGED, refresh); + globalThis.window.removeEventListener('focus', refresh); + }; + }, []); useEffect(() => { let active = true; @@ -99,9 +112,8 @@ const V2ActivityPage: React.FC = () => { setError(null); const token = localStorage.getItem('token'); const headers = { 'x-auth-token': token ?? '' }; - // The recap paints the room; the decision queue is the reason the page - // exists (TASK-083). They load together, but a queue failure must not - // blank the recap — degrade to recap.needsYou (mentions + approvals). + // Recap and attention are independent facts. A failed queue read must + // never substitute recap mentions or pretend the queue is empty. Promise.all([ axios.get('/api/activity/recap', { headers, @@ -110,6 +122,8 @@ const V2ActivityPage: React.FC = () => { axios.get<{ items: Array; composePodId?: string | null; + count: number; + countsByPod: Record; }>( '/api/activity/decision-queue', { headers }, @@ -118,9 +132,6 @@ const V2ActivityPage: React.FC = () => { .then(([recapResponse, queueResponse]) => { if (!active) return; setRecap(recapResponse.data); - // A well-formed queue response has an items ARRAY. Anything else — - // endpoint failed (null), older server, malformed body — degrades to - // recap.needsYou (mentions + approvals) rather than an empty queue. const rawItems = queueResponse?.data?.items; const availablePods = recapResponse.data.pods || []; const setComposeDefault = (candidate = '') => { @@ -129,11 +140,16 @@ const V2ActivityPage: React.FC = () => { current && availablePods.some((pod) => pod.id === current) ? current : fallback )); }; - if (!Array.isArray(rawItems)) { - setQueue(recapResponse.data.needsYou || []); + if (!Array.isArray(rawItems) || typeof queueResponse?.data?.count !== 'number' + || (podId !== 'all' && !queueResponse?.data?.countsByPod)) { + setQueue([]); + setQueueCount(null); + setQueueFailed(true); setComposeDefault(); return; } + setQueueFailed(false); + setQueueCount(podId === 'all' ? queueResponse!.data.count : (queueResponse!.data.countsByPod?.[podId] || 0)); const queueItems = rawItems.map((item) => ({ ...item, detail: item.detail || '', @@ -184,6 +200,7 @@ const V2ActivityPage: React.FC = () => { { headers: { 'x-auth-token': token ?? '' } }, ); if (!response.data?.success) throw new Error('Approval action failed'); + notifyAttentionChanged(); setReloadKey((value) => value + 1); } catch { setActionError(t('activity.approval.actionFailed')); @@ -204,6 +221,7 @@ const V2ActivityPage: React.FC = () => { { headers: { 'x-auth-token': token ?? '' } }, ); if (!response.data?.ok) throw new Error('Decision ruling failed'); + notifyAttentionChanged(); setOtherDecisionId(null); setOtherDecisionValue(''); setReloadKey((value) => value + 1); @@ -235,6 +253,7 @@ const V2ActivityPage: React.FC = () => { { headers: { 'x-auth-token': token ?? '' } }, ); setComposeDraft(''); + notifyAttentionChanged(); setReloadKey((value) => value + 1); } catch { setComposeError(t('activity.compose.actionFailed')); @@ -266,6 +285,7 @@ const V2ActivityPage: React.FC = () => { setRepliedIds((prev) => new Set(prev).add(item.id)); setReplyDrafts((prev) => ({ ...prev, [item.id]: '' })); if (item.attentionItemId) await axios.post(`/api/activity/${item.attentionItemId}/acknowledge`, {}, { headers: { 'x-auth-token': token ?? '' } }).catch(() => null); + notifyAttentionChanged(); setReloadKey((value) => value + 1); } catch { setActionError(t('activity.mention.actionFailed')); @@ -286,6 +306,7 @@ const V2ActivityPage: React.FC = () => { { headers: { 'x-auth-token': token ?? '' } }, ); if (!response.data?.success) throw new Error('Mention acknowledgement failed'); + notifyAttentionChanged(); setReloadKey((value) => value + 1); } catch { setActionError(t('activity.mention.actionFailed')); @@ -295,7 +316,7 @@ const V2ActivityPage: React.FC = () => { }; const isDayZero = podId === 'all' - && queue.length === 0 + && queueCount === 0 && recap?.agents.length === 0 && recap.board.length === 0; @@ -366,10 +387,10 @@ const V2ActivityPage: React.FC = () => {

{t('activity.needsYou.title')}

- {!isDayZero && queue.length > 0 && {queue.length}} + {!isDayZero && queueCount !== null && queueCount > 0 && {queueCount}}

{t('activity.needsYou.description')}

- {isDayZero ? ( + {queueFailed ?

{t('activity.loadFailed')}

: isDayZero ? (
@@ -411,8 +432,10 @@ const V2ActivityPage: React.FC = () => {
) : queue.length === 0 ? (
- {t('activity.needsYou.emptyTitle')} - {t('activity.needsYou.emptyDescription')} + {queueCount === 0 ? <> + {t('activity.needsYou.emptyTitle')} + {t('activity.needsYou.emptyDescription')} + : {t('activity.needsYou.countLabel', { count: queueCount })}}
) : (
diff --git a/frontend/src/v2/components/V2Inspector.tsx b/frontend/src/v2/components/V2Inspector.tsx index 1f95f5ae4..ec82f60cb 100644 --- a/frontend/src/v2/components/V2Inspector.tsx +++ b/frontend/src/v2/components/V2Inspector.tsx @@ -10,6 +10,7 @@ import { V2AttentionItem } from '../hooks/useV2PodAttention'; interface V2InspectorProps { detail: UseV2PodDetailResult; attentionItems?: V2AttentionItem[]; + attentionCount?: number | null; onClose?: () => void; onOpenInvite?: () => void; } @@ -62,7 +63,7 @@ const agentState = ( return { kind: 'idle' }; }; -const V2Inspector: React.FC = ({ detail, attentionItems = [], onClose, onOpenInvite }) => { +const V2Inspector: React.FC = ({ detail, attentionItems = [], attentionCount = null, onClose, onOpenInvite }) => { const { pod, agents } = detail; const api = useV2Api(); const navigate = useNavigate(); @@ -167,7 +168,12 @@ const V2Inspector: React.FC = ({ detail, attentionItems = [],

{t('inspector.workspace.needsYou')}

- {attention.length === 0 &&

{emptyAttentionCopy}

} + {attentionCount === 0 &&

{emptyAttentionCopy}

} + {attentionCount !== null && attentionCount > 0 && ( + + )} {attention.map((item) => ( {item.dividerAfter &&