import React, { useContext, useEffect, useMemo, useRef, useState } from 'react'; import axios from 'axios'; import { useLocation, useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { ActGlyph, KindGlyph } from '../icons/glyphs'; import { AuthContext } from '../../context/AuthContext'; import { ATTENTION_CHANGED, notifyAttentionChanged } from '../hooks/useV2PodAttention'; import V2PageHead from './V2PageHead'; import V2Hint from './V2Hint'; type ActivityWindow = 'today' | '7d'; interface ActivityUpdate { id: string; podId: string | null; podName: string; content: string; timestamp: string | null; } interface AgentRecap { id: string; name: string; profilePicture?: string; lastActiveAt: string | null; messageCount: number; recap: string; updates: ActivityUpdate[]; } // Pods carry a long descriptive title; the inbox names them by the short // leading name the inspector uses ("sharpen"), lowercase in mono. const shortPodName = (name: string): string => ( (name || '').trim().split(/\s*[·—–:|]\s*/)[0].trim() || (name || '').trim() ).toLowerCase(); interface NeedsYouItem { id: string; attentionItemId?: string; actorName?: string; actorUserId?: string; // Which store the row came from — an agent's action proposal ('approval_action') // resolves through /api/approvals, a scope approval through /api/activity (#1650). sourceType?: string; // A settled decision keeps its ruling (from durable history) so the 0-state // can say when the last ask was answered. ruling?: { value?: string; by?: string; at?: string | null } | null; kind: 'mention' | 'approval' | 'decision' | 'handoff' | 'blocked'; title: string; detail: string; podId: string | null; podName: string; options?: Array<{ label: string; description?: string; recommended?: boolean }>; timestamp: string | null; // Mention rows carry where they live so a reply can land IN the thread. messageId?: number | string; threadRootId?: number | string; } interface BoardItem { id: string; taskId: string; title: string; status: 'pending' | 'claimed' | 'blocked' | 'done'; podId: string; podName: string; updatedAt: string | null; lastUpdate: { text: string; author: string; createdAt: string | null } | null; } interface ActivityRecap { pods: Array<{ id: string; name: string }>; // Account-level durable fact: once any attention item has existed, the // first-ask onboarding card should not return after the queue is resolved. hasEverHadAttention?: boolean | null; needsYou: NeedsYouItem[]; agents: AgentRecap[]; board: BoardItem[]; } interface QueueResponse { items: Array; composePodId?: string | null; count: number; countsByPod: Record; countsByKind?: Record; offset?: number; limit?: number; remaining?: number; hasMore?: boolean; } interface DecisionHistoryResponse { items?: Array; count?: number; remaining?: number; hasMore?: boolean; } interface MovedLine { id: string; author: string; text: string; timestamp: string | null; } interface MovedGroup { id: string; name: string; lines: MovedLine[]; } interface ActivitySnapshot { window?: ActivityWindow; podId?: string; recap?: ActivityRecap | null; queue?: NeedsYouItem[]; queueCount?: number | null; queueRemaining?: number; queueCountsByPod?: Record; composePodId?: string; composeDraft?: string; replyDrafts?: Record; focusedItemId?: string | null; scrollY?: number; savedAt?: number; } const ACTIVITY_SNAPSHOT_KEY = 'v2:activity:snapshot'; const snapshotKey = (accountId: string): string => `${ACTIVITY_SNAPSHOT_KEY}:${accountId}`; const readActivitySnapshot = (accountId: string): ActivitySnapshot | null => { try { const raw = sessionStorage.getItem(snapshotKey(accountId)); if (!raw) return null; const parsed = JSON.parse(raw) as ActivitySnapshot; if (!parsed.savedAt || Date.now() - parsed.savedAt > 10 * 60_000) { sessionStorage.removeItem(snapshotKey(accountId)); return null; } return parsed; } catch { return null; } }; const relativeTime = (value: string | null | undefined): string => { if (!value) return ''; const elapsed = Math.max(0, Date.now() - new Date(value).getTime()); const minutes = Math.floor(elapsed / 60_000); if (minutes < 1) return 'now'; if (minutes < 60) return `${minutes}m`; const hours = Math.floor(minutes / 60); if (hours < 24) return `${hours}h`; return `${Math.floor(hours / 24)}d`; }; const V2ActivityPage: React.FC = () => { const location = useLocation(); const navigate = useNavigate(); const { t } = useTranslation(); const auth = useContext(AuthContext); const currentUser = auth?.currentUser || null; const authLoading = auth?.loading || false; const accountId = currentUser?._id ? String(currentUser._id) : null; const emailTargetRef = useRef(new URLSearchParams(location.search).get('item')); const restoredSnapshotRef = useRef(null); const snapshotAccountRef = useRef(undefined); const [snapshotReady, setSnapshotReady] = useState(false); const [window, setWindow] = useState('today'); const [podId, setPodId] = useState('all'); const [scopeMenuOpen, setScopeMenuOpen] = useState(false); const [recap, setRecap] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [reloadKey, setReloadKey] = useState(0); const [actingApprovalId, setActingApprovalId] = useState(null); const [acknowledgingAttentionId, setAcknowledgingAttentionId] = useState(null); const [actionError, setActionError] = useState(null); const [actionErrorItemId, setActionErrorItemId] = useState(null); const [rulingId, setRulingId] = useState(null); const [otherDecisionId, setOtherDecisionId] = useState(null); const [otherDecisionValue, setOtherDecisionValue] = useState(''); const [ruledDecisions, setRuledDecisions] = useState>({}); const [settledQueueDecisions, setSettledQueueDecisions] = useState>({}); const [queue, setQueue] = useState([]); const queueRef = useRef([]); const [queueCount, setQueueCount] = useState(null); const [queueCountsByPod, setQueueCountsByPod] = useState>({}); const [queueRemaining, setQueueRemaining] = useState(0); // True while another page is expected to load itself. The way-back control // renders only when pages remain and nothing is expected to fetch them — // a stall, which the auto-load effect makes unreachable in practice // (sprint-review 66408: deterministic under the fold, never Loading). const [queueAutoPending, setQueueAutoPending] = useState(false); // Day zero (ux-lead 66658/66666): Get started is its own card above Needs you and a step is // not an ask — no actor, no ring, no count, no badge; a step leaves when the account does // something. hire ← an agent exists; speak ← you said something in a pod that has a seat; // connect ← a connector row. const [connectorCount, setConnectorCount] = useState(null); // "Hired" and "spoke" are facts about the account's seats, not about the last 24h // (sprint-review 66671: recap.agents only carries agents that acted inside the recap window). // The registry's per-pod agent list is what Your Team reads. Step 2 closes on the caller's own // act (#1648, ux-lead 67071): `callerSpoke` is the account's own message in that pod, reply or // not. A seat's `lastMessage` cannot carry it — an install intro sets it before anyone spoke — // and `lastActiveAt` is only proof of life (provisioning sets it, sprint-review 66678). const [seatPods, setSeatPods] = useState }> | null>(null); const [queueLoadingMore, setQueueLoadingMore] = useState(false); const [queueMoreError, setQueueMoreError] = useState(false); const [historyRemaining, setHistoryRemaining] = useState(0); const [historyLoadingMore, setHistoryLoadingMore] = useState(false); const [historyMoreError, setHistoryMoreError] = useState(false); const [queueFailed, setQueueFailed] = useState(false); const [queueHydrated, setQueueHydrated] = useState(false); const actionFocusGenerationRef = useRef(0); const queueScopeRef = useRef('all'); const queueGenerationRef = useRef(0); const revalidationExtentRef = useRef(0); const revalidationScopeRef = useRef(null); const queueMoreButtonRef = useRef(null); const queueMoreFailureOffsetRef = useRef(null); const historyMoreButtonRef = useRef(null); const historyOffsetRef = useRef(0); const pendingRefreshFocusRef = useRef(null); const [replyOpenIds, setReplyOpenIds] = useState>(new Set()); const [expandedDetailIds, setExpandedDetailIds] = useState>(new Set()); // A reader who collapsed a detail keeps focus on it; this stops the focus // preview from re-opening it until the button is blurred and focused again. const [dismissedDetailIds, setDismissedDetailIds] = useState>(new Set()); const [replyDrafts, setReplyDrafts] = useState>({}); const [composePodId, setComposePodId] = useState(''); const [composeDraft, setComposeDraft] = useState(''); const [composeMenuOpen, setComposeMenuOpen] = useState(false); const composePickerRef = useRef(null); const composePickerButtonRef = useRef(null); const composePickerOptionRefs = useRef>({}); const scopeMenuButtonRef = useRef(null); const [composing, setComposing] = useState(false); const [composeError, setComposeError] = useState(null); const [movedVisibleCounts, setMovedVisibleCounts] = useState>({}); // A Back snapshot is account-scoped and is only read after AuthContext has // established the identity. This prevents one signed-in account from // hydrating another account's queue, recap, or reply drafts from a shared // browser session. useEffect(() => { if (authLoading || snapshotAccountRef.current === accountId) return; snapshotAccountRef.current = accountId; const snapshot = accountId ? readActivitySnapshot(accountId) : null; const fromEmail = Boolean(emailTargetRef.current); // A saved Back snapshot can be scoped to a different pod. An email link // names a particular ask, so search across pods and let that ask win focus. restoredSnapshotRef.current = fromEmail ? null : snapshot; if (fromEmail && accountId) sessionStorage.removeItem(snapshotKey(accountId)); setRuledDecisions({}); setSettledQueueDecisions({}); setHistoryRemaining(0); setHistoryMoreError(false); setHistoryLoadingMore(false); historyOffsetRef.current = 0; if (snapshot) { const startingScope = fromEmail ? 'all' : snapshot.podId || 'all'; const startingQueue = fromEmail ? [] : snapshot.queue || []; setWindow(snapshot.window || 'today'); setPodId(startingScope); setRecap(snapshot.recap || null); queueScopeRef.current = startingScope; queueRef.current = startingQueue; setQueue(queueRef.current); setQueueCount(fromEmail ? null : snapshot.queueCount ?? null); setQueueCountsByPod(fromEmail ? {} : snapshot.queueCountsByPod || {}); setQueueRemaining(fromEmail ? 0 : snapshot.queueRemaining || 0); setReplyDrafts(snapshot.replyDrafts || {}); setComposePodId(snapshot.composePodId || ''); setComposeDraft(snapshot.composeDraft || ''); setComposeMenuOpen(false); revalidationExtentRef.current = startingQueue.length; revalidationScopeRef.current = startingScope; setLoading(!snapshot.recap); } else { setWindow('today'); setPodId('all'); setRecap(null); queueScopeRef.current = 'all'; queueRef.current = []; setQueue(queueRef.current); setQueueCount(null); setQueueCountsByPod({}); setQueueRemaining(0); setReplyDrafts({}); setComposePodId(''); setComposeDraft(''); setComposeMenuOpen(false); revalidationExtentRef.current = 0; revalidationScopeRef.current = null; setLoading(true); } setQueueHydrated(false); setSnapshotReady(true); }, [accountId, authLoading]); useEffect(() => { const refresh = () => { const activeRow = document.activeElement?.closest('[data-activity-item-id]'); pendingRefreshFocusRef.current = activeRow?.dataset.activityItemId || null; 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(() => { if (!composeMenuOpen) return undefined; const closeOnOutsidePress = (event: MouseEvent | TouchEvent) => { const target = event.target; if (target instanceof Node && composePickerRef.current && !composePickerRef.current.contains(target)) { setComposeMenuOpen(false); } }; document.addEventListener('mousedown', closeOnOutsidePress); document.addEventListener('touchstart', closeOnOutsidePress); return () => { document.removeEventListener('mousedown', closeOnOutsidePress); document.removeEventListener('touchstart', closeOnOutsidePress); }; }, [composeMenuOpen]); useEffect(() => { const snapshot = restoredSnapshotRef.current; if (!snapshotReady || !snapshot || !queueHydrated) return; const restore = () => { if (snapshot.scrollY && snapshot.scrollY > 0) globalThis.window.scrollTo(0, snapshot.scrollY); if (snapshot.focusedItemId) { const row = document.querySelector(`[data-activity-item-id="${CSS.escape(snapshot.focusedItemId)}"]`); row?.focus(); } if (accountId) sessionStorage.removeItem(snapshotKey(accountId)); restoredSnapshotRef.current = null; }; const frame = globalThis.window.requestAnimationFrame(restore); return () => globalThis.window.cancelAnimationFrame(frame); }, [accountId, queueHydrated, snapshotReady]); useEffect(() => { if (!snapshotReady) return undefined; let active = true; const generation = queueGenerationRef.current + 1; queueGenerationRef.current = generation; const previousScope = queueScopeRef.current; queueScopeRef.current = podId; const sameScope = previousScope === podId; const previousQueue = sameScope ? queueRef.current : []; const previousHistoryExtent = sameScope ? historyOffsetRef.current : 0; if (!sameScope) { // Retained rows are evidence for their own scope only. If the new // request fails, showing them under the newly selected pod is false. queueRef.current = []; setQueue([]); setQueueCount(null); setQueueRemaining(0); setHistoryRemaining(0); setHistoryMoreError(false); setHistoryLoadingMore(false); historyOffsetRef.current = 0; setQueueFailed(false); revalidationExtentRef.current = 0; revalidationScopeRef.current = null; } // Refreshes in the same scope must revalidate every loaded page before // replacing the visible queue. Otherwise a 56-row queue briefly regresses // to the first 50 rows while the refresh response is settling. if (sameScope && previousQueue.length > 0) { revalidationScopeRef.current = podId; revalidationExtentRef.current = Math.max(revalidationExtentRef.current, previousQueue.length); } queueMoreFailureOffsetRef.current = null; setQueueHydrated(false); setLoading((current) => (sameScope && recap ? current : true)); setError(null); setQueueMoreError(false); setQueueLoadingMore(false); setHistoryLoadingMore(false); const token = localStorage.getItem('token'); const headers = { 'x-auth-token': token ?? '' }; // 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, params: { window, ...(podId !== 'all' ? { podId } : {}) }, }), axios.get( '/api/activity/decision-queue', { headers, params: { limit: 50, offset: 0, ...(podId !== 'all' ? { podId } : {}) } }, ).catch(() => null), axios.get('/api/activity/decision-history', { headers, params: { limit: 50, offset: 0, ...(podId !== 'all' ? { podId } : {}) }, }).catch(() => null), ]) .then(async ([recapResponse, queueResponse, historyResponse]) => { if (!active) return; setRecap(recapResponse.data); // Decision history is a durable pod projection. Hydrate it on every // Activity read so a settled card survives a hard reload and the // Activity → pod → Activity Back path, even when the open queue has // already dropped the recipient-owned row. const historyItems = Array.isArray(historyResponse?.data?.items) ? historyResponse.data.items : []; const settledHistory = historyItems.filter((item) => ( item.kind === 'decision' && item.id && item.ruling?.value )); if (historyResponse) { const historyCount = typeof historyResponse.data?.count === 'number' ? historyResponse.data.count : historyItems.length; // A refresh revalidates the first page but must not collapse an // explicitly loaded history extent back to its first-page boundary. // Keep the prior extent as the cursor; older rows remain durable in // settledQueueDecisions and can be fetched only when the reader asks // for another page. const loadedExtent = Math.max(historyItems.length, previousHistoryExtent); historyOffsetRef.current = loadedExtent; if (sameScope && previousHistoryExtent > 0) { // A new ruling can arrive ahead of an already-loaded older page. // Count the union of durable cards we already rendered and this // refreshed first page; using only the previous offset would // report the new row as an unseen older row. const existingIds = new Set(Object.values(settledQueueDecisions) .filter((item) => podId === 'all' || item.podId === podId) .map((item) => String(item.id))); settledHistory.forEach((item) => existingIds.add(String(item.id))); setHistoryRemaining(Math.max(historyCount - existingIds.size, 0)); } else { setHistoryRemaining(typeof historyResponse.data?.remaining === 'number' ? historyResponse.data.remaining : Math.max(historyCount - historyItems.length, 0)); } setHistoryMoreError(false); } if (settledHistory.length > 0) { setRuledDecisions((current) => { const next = { ...current }; settledHistory.forEach((item) => { next[String(item.id)] = { value: String(item.ruling?.value), ...(item.ruling?.by ? { by: item.ruling.by } : {}), }; }); return next; }); setSettledQueueDecisions((current) => { const next = { ...current }; settledHistory.forEach((item) => { next[String(item.id)] = { ...item, detail: item.detail || '', podName: item.podName || '', timestamp: item.timestamp ?? item.createdAt ?? null, }; }); return next; }); } const rawItems = queueResponse?.data?.items; const availablePods = recapResponse.data.pods || []; const setComposeDefault = (candidate = '') => { const fallback = candidate || availablePods[0]?.id || ''; setComposePodId((current) => ( current && availablePods.some((pod) => pod.id === current) ? current : fallback )); }; if (!Array.isArray(rawItems) || typeof queueResponse?.data?.count !== 'number' || (podId !== 'all' && !queueResponse?.data?.countsByPod)) { setQueueFailed(previousQueue.length === 0 && settledHistory.length === 0); setQueueMoreError(true); setComposeDefault(); return; } setQueueFailed(false); setQueueCount(queueResponse!.data.count); setQueueCountsByPod(queueResponse!.data.countsByPod || {}); const mapQueueItems = (items: QueueResponse['items']) => items.map((item) => ({ ...item, detail: item.detail || '', podName: item.podName || '', timestamp: item.timestamp ?? item.createdAt ?? null, })); let queueItems = mapQueueItems(rawItems); // A Back snapshot can contain more than one server page. Revalidate // the loaded extent before replacing it, otherwise a 56-row snapshot // briefly regresses to the first 50 and loses its final six rows. const revalidateTo = revalidationScopeRef.current === podId ? revalidationExtentRef.current : 0; let nextOffset = queueItems.length; let nextRemaining = typeof queueResponse!.data.remaining === 'number' ? queueResponse!.data.remaining : Math.max(queueResponse!.data.count - nextOffset, 0); while (nextOffset < revalidateTo && nextRemaining > 0) { const nextResponse = await axios.get('/api/activity/decision-queue', { headers, params: { limit: 50, offset: nextOffset, ...(podId !== 'all' ? { podId } : {}) }, }); const nextPage = mapQueueItems(nextResponse.data?.items || []); if (!nextPage.length) break; const existing = new Set(queueItems.map((item) => `${item.kind}:${item.id}`)); queueItems = [...queueItems, ...nextPage.filter((item) => !existing.has(`${item.kind}:${item.id}`))]; nextOffset += nextPage.length; nextRemaining = typeof nextResponse.data?.remaining === 'number' ? nextResponse.data.remaining : Math.max((nextResponse.data?.count || 0) - nextOffset, 0); } revalidationExtentRef.current = 0; revalidationScopeRef.current = null; queueRef.current = queueItems; setQueue(queueItems); setQueueRemaining(Math.max(queueResponse!.data.count - queueItems.length, 0)); setQueueAutoPending(Math.max(queueResponse!.data.count - queueItems.length, 0) > 0); // The initial destination is an account-level global fact computed by // the service before scope/page slicing. Preserve an intentional // target across refreshes and fall back to the first available pod. setComposeDefault(queueResponse?.data?.composePodId || ''); const pendingFocus = pendingRefreshFocusRef.current; pendingRefreshFocusRef.current = null; if (pendingFocus) { globalThis.window.requestAnimationFrame(() => { document.querySelector(`[data-activity-item-id="${CSS.escape(pendingFocus)}"]`)?.focus(); }); } }) .catch(() => { if (active) { if (recap || previousQueue.length > 0) { setQueueFailed(false); setQueueMoreError(true); } else { setError(t('activity.loadFailed')); setQueueFailed(true); } } }) .finally(() => { if (active) { setQueueHydrated(true); setLoading(false); } }); return () => { active = false; }; }, [accountId, podId, reloadKey, snapshotReady, t, window]); useEffect(() => { if (!snapshotReady) return undefined; let active = true; const token = localStorage.getItem('token'); axios.get>('/api/integrations/user/all', { headers: { 'x-auth-token': token ?? '' } }) .then((res) => { if (active) setConnectorCount(Array.isArray(res.data) ? res.data.filter((row) => row.status !== 'error').length : 0); }) .catch(() => { if (active) setConnectorCount(0); }); return () => { active = false; }; }, [snapshotReady, reloadKey]); useEffect(() => { if (!recap || podId !== 'all') return undefined; let active = true; const token = localStorage.getItem('token'); const headers = { 'x-auth-token': token ?? '' }; Promise.all(recap.pods.slice(0, 20).map((pod) => axios .get<{ agents?: Array<{ name: string; internal?: boolean }>; callerSpoke?: boolean }>(`/api/registry/pods/${pod.id}/agents`, { headers }) .then((res) => ({ podId: pod.id, callerSpoke: res.data?.callerSpoke === true, agents: (Array.isArray(res.data?.agents) ? res.data.agents : []).filter((agent) => !agent.internal), })) .catch(() => ({ podId: pod.id, callerSpoke: false, agents: [] })))) .then((pods) => { if (active) setSeatPods(pods); }); return () => { active = false; }; }, [recap, podId]); // The pods that hold a seat the account can speak to; step 2 lands the person in the first one. const seatedPods = useMemo(() => (seatPods ?? []).filter((pod) => pod.agents.length > 0), [seatPods]); const startSteps = useMemo(() => { if (!recap || podId !== 'all' || seatPods === null) return [] as Array<'hire' | 'speak' | 'connect'>; const open: Array<'hire' | 'speak' | 'connect'> = []; if (seatedPods.length === 0) open.push('hire'); if (!seatedPods.some((pod) => pod.callerSpoke)) open.push('speak'); if (connectorCount !== null && connectorCount === 0) open.push('connect'); return open; }, [recap, podId, seatPods, seatedPods, connectorCount]); // Step 2 names the pod it lands in (ux-lead 67327): the first pod holding a seat, else the // account's first pod — the one step 1 will seat an agent in. const speakPod = useMemo(() => { const target = seatedPods[0]?.podId ?? recap?.pods[0]?.id; return target ? { id: target, name: recap?.pods.find((pod) => pod.id === target)?.name ?? '' } : null; }, [seatedPods, recap]); // Get started never pops in (d215 s-activity, walk 11-02): on an account the card can show for, // its steps wait on the roster and the connector count. While either is in flight the card // holds its place as a same-height placeholder, so Needs you does not move when they land. // Eligibility is the card's own gate, so a failed or unknown history still shows nothing. const startEligible = queueCount === 0 && recap?.hasEverHadAttention === false && podId === 'all'; const startPending = startEligible && (seatPods === null || connectorCount === null); const startStepTarget = (step: 'hire' | 'speak' | 'connect') => { if (step === 'hire') return '/v2/agents'; if (step === 'speak') return speakPod ? `/v2/pods/${speakPod.id}` : '/v2'; return '/v2/connectors'; }; const loadMoreQueue = async (auto = false) => { if (queueMoreError && queueMoreFailureOffsetRef.current === null) { setReloadKey((value) => value + 1); return; } if (queueLoadingMore || queueRemaining <= 0 || queueFailed) return; const requestedScope = podId; const requestedGeneration = queueGenerationRef.current; const offset = queueMoreFailureOffsetRef.current ?? queue.length; setQueueLoadingMore(true); setQueueMoreError(false); try { const token = localStorage.getItem('token'); const response = await axios.get('/api/activity/decision-queue', { headers: { 'x-auth-token': token ?? '' }, params: { limit: 50, offset, ...(requestedScope !== 'all' ? { podId: requestedScope } : {}) }, }); if (queueScopeRef.current !== requestedScope || queueGenerationRef.current !== requestedGeneration) return; const nextItems = (response.data?.items || []).map((item) => ({ ...item, detail: item.detail || '', podName: item.podName || '', timestamp: item.timestamp ?? item.createdAt ?? null, })); setQueue((current) => { const existing = new Set(current.map((item) => `${item.kind}:${item.id}`)); const nextQueue = [...current, ...nextItems.filter((item) => !existing.has(`${item.kind}:${item.id}`))]; queueRef.current = nextQueue; return nextQueue; }); const loaded = offset + nextItems.length; queueMoreFailureOffsetRef.current = null; const nextRemaining = typeof response.data?.remaining === 'number' ? response.data.remaining : Math.max((response.data?.count || queueCount || 0) - loaded, 0); setQueueRemaining(nextRemaining); setQueueAutoPending(nextRemaining > 0); if (auto) return; globalThis.window.requestAnimationFrame(() => { if (queueMoreButtonRef.current) { queueMoreButtonRef.current.focus(); return; } const firstAdded = nextItems[0]?.id; if (firstAdded) document.querySelector(`[data-activity-item-id="${CSS.escape(String(firstAdded))}"]`)?.focus(); }); } catch { if (queueScopeRef.current === requestedScope && queueGenerationRef.current === requestedGeneration) { queueMoreFailureOffsetRef.current = offset; setQueueMoreError(true); setQueueAutoPending(false); } } finally { if (queueGenerationRef.current === requestedGeneration) setQueueLoadingMore(false); } }; // Needs you never folds (66311): every open ask renders, so the pages after // the first load themselves. A failed page stops here and offers Retry; // the count stays the ledger's while the rows catch up. // Not while a refresh is in flight: the retained rows still say "1 remaining" // until the new first page lands, and a page fetched in that window would // carry the new generation and append a row the refresh just discarded. useEffect(() => { if (!queueHydrated || queue.length === 0 || queueRemaining <= 0 || queueLoadingMore || queueMoreError || queueFailed) return; void loadMoreQueue(true); // eslint-disable-next-line react-hooks/exhaustive-deps }, [queueHydrated, queue.length, queueRemaining, queueLoadingMore, queueMoreError, queueFailed]); useEffect(() => { const target = emailTargetRef.current; if (!target || !queueHydrated || !queue.some((item) => item.attentionItemId === target)) return; emailTargetRef.current = null; globalThis.window.requestAnimationFrame(() => { const row = document.querySelector(`[data-attention-id="${CSS.escape(target)}"]`); row?.scrollIntoView({ block: 'center' }); row?.focus({ preventScroll: true }); }); }, [queue, queueHydrated]); // Stall detector (sprint-review 66417): a next page is expected, nothing is // fetching it, and no error cleared the expectation. Normal paging starts // the fetch in the same commit and cancels this timer; if 1.5s pass with // pages remaining and nothing in flight, the expectation is wrong and the // way back renders. useEffect(() => { if (!queueAutoPending || queueLoadingMore || queueRemaining <= 0 || queueMoreError) return undefined; const timer = globalThis.window.setTimeout(() => setQueueAutoPending(false), 1500); return () => globalThis.window.clearTimeout(timer); }, [queueAutoPending, queueLoadingMore, queueRemaining, queueMoreError]); const loadMoreHistory = async () => { if (historyLoadingMore || historyRemaining <= 0) return; const requestedScope = podId; const requestedGeneration = queueGenerationRef.current; const offset = historyOffsetRef.current; const trigger = historyMoreButtonRef.current; const shouldRestoreFocus = () => { const active = document.activeElement; return active === document.body || active === trigger || active === historyMoreButtonRef.current; }; setHistoryLoadingMore(true); setHistoryMoreError(false); try { const token = localStorage.getItem('token'); const response = await axios.get('/api/activity/decision-history', { headers: { 'x-auth-token': token ?? '' }, params: { limit: 50, offset, ...(requestedScope !== 'all' ? { podId: requestedScope } : {}) }, }); if (queueScopeRef.current !== requestedScope || queueGenerationRef.current !== requestedGeneration) return; const items = Array.isArray(response.data?.items) ? response.data.items : []; const settled = items.filter((item) => item.kind === 'decision' && item.id && item.ruling?.value); const existingSettledIds = new Set(Object.keys(settledQueueDecisions)); const existingQueueIds = new Set(queue.map((item) => `${item.kind}:${item.id}`)); const firstAddedId = settled.find((item) => ( !existingSettledIds.has(String(item.id)) && !existingQueueIds.has(`${item.kind}:${item.id}`) ))?.id; if (settled.length > 0) { setRuledDecisions((current) => { const next = { ...current }; settled.forEach((item) => { next[String(item.id)] = { value: String(item.ruling?.value), ...(item.ruling?.by ? { by: item.ruling.by } : {}), }; }); return next; }); setSettledQueueDecisions((current) => { const next = { ...current }; settled.forEach((item) => { next[String(item.id)] = { ...item, detail: item.detail || '', podName: item.podName || '', timestamp: item.timestamp ?? item.createdAt ?? null, }; }); return next; }); } historyOffsetRef.current = offset + items.length; const remaining = typeof response.data?.remaining === 'number' ? response.data.remaining : items.length > 0 && typeof response.data?.count === 'number' ? Math.max(response.data.count - historyOffsetRef.current, 0) : 0; setHistoryRemaining(remaining); setHistoryMoreError(false); globalThis.window.requestAnimationFrame(() => { if (!shouldRestoreFocus()) return; if (historyMoreButtonRef.current) { historyMoreButtonRef.current.focus(); return; } if (firstAddedId) { document.querySelector(`[data-activity-item-id="${CSS.escape(String(firstAddedId))}"]`)?.focus(); } }); } catch { if (queueScopeRef.current === requestedScope && queueGenerationRef.current === requestedGeneration) { setHistoryMoreError(true); globalThis.window.requestAnimationFrame(() => { if (shouldRestoreFocus()) historyMoreButtonRef.current?.focus(); }); } } finally { if (queueGenerationRef.current === requestedGeneration) setHistoryLoadingMore(false); } }; const movedGroups = useMemo(() => { if (!recap) return []; const groups = new Map(); const add = (podId: string | null | undefined, podName: string | undefined, line: MovedLine) => { const id = podId || `name:${podName || 'unknown'}`; const group = groups.get(id) || { id, name: podName || t('activity.movedForward.unknownPod'), lines: [] }; group.lines.push(line); groups.set(id, group); }; recap.agents.forEach((agent) => agent.updates.forEach((update) => add(update.podId, update.podName, { id: `agent:${agent.id}:${update.id}`, author: agent.name, text: update.content, timestamp: update.timestamp, }))); recap.board.forEach((item) => { if (!item.lastUpdate) return; add(item.podId, item.podName, { id: `board:${item.id}`, author: item.lastUpdate.author, text: `${item.title} — ${item.lastUpdate.text}`, timestamp: item.lastUpdate.createdAt || item.updatedAt, }); }); return [...groups.values()] .map((group) => ({ ...group, lines: [...group.lines].sort((a, b) => new Date(b.timestamp || 0).getTime() - new Date(a.timestamp || 0).getTime()), })) .sort((a, b) => new Date(b.lines[0]?.timestamp || 0).getTime() - new Date(a.lines[0]?.timestamp || 0).getTime()); }, [recap, t]); const scopedPods = useMemo(() => { return recap?.pods || []; }, [recap]); const composePodName = scopedPods.find((pod) => pod.id === composePodId)?.name || t('activity.allPods'); const selectComposePod = (nextPodId: string) => { setComposePodId(nextPodId); setComposeMenuOpen(false); globalThis.window.requestAnimationFrame(() => composePickerButtonRef.current?.focus()); }; const focusComposePickerOption = (index: number) => { if (scopedPods.length === 0) return; const boundedIndex = (index + scopedPods.length) % scopedPods.length; const option = scopedPods[boundedIndex]; if (!option) return; setComposeMenuOpen(true); globalThis.window.requestAnimationFrame(() => composePickerOptionRefs.current[option.id]?.focus()); }; // One seat has one mark everywhere (66390): an agent actor takes the chat's // cobalt mark, a human the tint mark. The kind glyph stays for asks with no actor. // Rows written before the actor id was stamped still carry the name, so the // name is the second door. const agentUserIds = useMemo(() => new Set((recap?.agents || []).map((agent) => String(agent.id))), [recap]); const agentNames = useMemo(() => new Set((recap?.agents || []).map((agent) => agent.name.trim().toLowerCase())), [recap]); // The id is the authority when it exists — a failed id lookup means "not an // agent", not "unknown" — so the name door opens only for rows with no id. const isAgentActor = (item: NeedsYouItem): boolean => ( item.actorUserId ? agentUserIds.has(item.actorUserId) : (!!item.actorName && agentNames.has(item.actorName.trim().toLowerCase())) ); // 0-state (66400 fix 5): "Nothing needs you." and, when the history knows it, // when the last ask was answered — the newest ruling in this scope. const lastAnsweredAt = useMemo(() => { const times = Object.values(settledQueueDecisions) .filter((item) => podId === 'all' || item.podId === podId) .map((item) => item.ruling?.at) .filter((at): at is string => typeof at === 'string' && at.length > 0) .map((at) => new Date(at).getTime()) .filter((ms) => Number.isFinite(ms)); return times.length ? new Date(Math.max(...times)).toISOString() : null; }, [podId, settledQueueDecisions]); const visibleQueue = useMemo(() => { const settled = Object.values(settledQueueDecisions) .filter((item) => podId === 'all' || item.podId === podId) .filter((item) => !queue.some((open) => open.id === item.id)); // Ruling 66311: Needs you never folds and runs oldest waiting first — // the ask that has waited longest sits at the top. Settled cards follow. const open = [...queue].sort((a, b) => ( new Date(a.timestamp || 0).getTime() - new Date(b.timestamp || 0).getTime() )); return [...open, ...settled]; }, [podId, queue, settledQueueDecisions]); const openPod = (targetPodId: string | null, messageId?: number | string) => { if (!targetPodId) return; try { const active = document.activeElement?.closest('[data-activity-item-id]'); if (accountId) sessionStorage.setItem(snapshotKey(accountId), JSON.stringify({ window, podId, recap, queue, queueCount, queueRemaining, queueCountsByPod, replyDrafts, composePodId, composeDraft, focusedItemId: active?.dataset.activityItemId || null, scrollY: globalThis.window.scrollY, savedAt: Date.now(), } satisfies ActivitySnapshot)); } catch { // Navigation should still work if storage is unavailable or full. } const target = messageId === undefined || messageId === null || messageId === '' ? '' : `#message-${String(messageId)}`; navigate(`/v2/pods/${targetPodId}${target}`); }; const captureActionFocus = (item: NeedsYouItem) => { const generation = ++actionFocusGenerationRef.current; const active = document.activeElement; const row = active?.closest('[data-activity-item-id]'); const target = active instanceof HTMLElement && row?.dataset.activityItemId === item.id ? active : null; // Each request owns its target. A later action supersedes older recovery, // and an intentional focus move must survive an eventual failed request. return () => { if (!target) return; globalThis.window.requestAnimationFrame(() => { const current = document.activeElement; if (generation === actionFocusGenerationRef.current && document.contains(target) && (current === document.body || current === target)) { target.focus({ preventScroll: true }); } }); }; }; const actOnApproval = async (item: NeedsYouItem, action: 'approve' | 'reject') => { if (actingApprovalId) return; const restoreActionFocus = captureActionFocus(item); setActingApprovalId(item.id); setActionError(null); setActionErrorItemId(null); try { const token = localStorage.getItem('token'); const headers = { 'x-auth-token': token ?? '' }; if (item.sourceType === 'approval_action') { // A runtime action proposal: the approvals store decides it, and answers { ok }. const response = await axios.post<{ ok?: boolean }>( `/api/approvals/${encodeURIComponent(item.id)}/resolve`, { decision: action === 'approve' ? 'approved' : 'declined' }, { headers }, ); if (!response.data?.ok) throw new Error('Approval action failed'); } else { const response = await axios.post<{ success?: boolean }>( `/api/activity/${encodeURIComponent(item.id)}/${action}`, { notes: `${action === 'approve' ? 'Approved' : 'Rejected'} via Activity` }, { headers }, ); if (!response.data?.success) throw new Error('Approval action failed'); } notifyAttentionChanged(); setReloadKey((value) => value + 1); } catch { setActionErrorItemId(item.id); setActionError(t('activity.approval.actionFailed')); restoreActionFocus(); } finally { setActingApprovalId(null); } }; const ruleDecision = async (item: NeedsYouItem, value: string) => { if (rulingId || !value.trim()) return; const restoreActionFocus = captureActionFocus(item); setRulingId(item.id); setActionError(null); setActionErrorItemId(null); try { const token = localStorage.getItem('token'); const response = await axios.post<{ ok?: boolean; decision?: { ruling?: { value?: string; by?: string } | null }; }>( `/api/activity/decisions/${encodeURIComponent(item.id)}/choose`, { value }, { headers: { 'x-auth-token': token ?? '' } }, ); if (!response.data?.ok) throw new Error('Decision ruling failed'); const settled = response.data.decision?.ruling; const ruling = { value: settled?.value || value.trim(), ...(settled?.by ? { by: settled.by } : {}) }; setRuledDecisions((current) => ({ ...current, [item.id]: ruling, })); setSettledQueueDecisions((current) => ({ ...current, [item.id]: item })); notifyAttentionChanged(); setOtherDecisionId(null); setOtherDecisionValue(''); setReloadKey((value) => value + 1); } catch (error) { const standing = axios.isAxiosError(error) ? error.response?.data?.decision?.ruling : null; if (standing?.value) { const ruling = { value: standing.value, ...(standing.by ? { by: standing.by } : {}) }; setRuledDecisions((current) => ({ ...current, [item.id]: ruling, })); setSettledQueueDecisions((current) => ({ ...current, [item.id]: item })); } else { setActionErrorItemId(item.id); setActionError(t('activity.decision.actionFailed')); restoreActionFocus(); } } finally { setRulingId(null); } }; const sendCompose = async () => { const content = composeDraft.trim(); if (!content || !composePodId || composing) return; setComposing(true); setComposeError(null); try { const token = localStorage.getItem('token'); await axios.post( `/api/messages/${encodeURIComponent(composePodId)}`, { content }, { headers: { 'x-auth-token': token ?? '' } }, ); setComposeDraft(''); notifyAttentionChanged(); setReloadKey((value) => value + 1); } catch { setComposeError(t('activity.compose.actionFailed')); } finally { setComposing(false); } }; // Reply-in-place (2026-09-01: "a way to really work with these agents // more easily… and tell them what is on my mind"). The reply posts into // the SAME thread the mention came from, addressed to the message, through // the ordinary messages route — so the agent gets the normal implicit-reply // wake — and then the mention is acknowledged. const [replyingId, setReplyingId] = useState(null); const [repliedIds, setRepliedIds] = useState>(new Set()); const sendReply = async (item: NeedsYouItem) => { const content = (replyDrafts[item.id] || '').trim(); if (!content || replyingId || !item.podId) return; const restoreActionFocus = captureActionFocus(item); setReplyingId(item.id); setActionError(null); setActionErrorItemId(null); try { const token = localStorage.getItem('token'); await axios.post( `/api/messages/${item.podId}`, { content, threadRootId: item.threadRootId, replyToMessageId: item.messageId }, { headers: { 'x-auth-token': token ?? '' } }, ); 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 { setActionErrorItemId(item.id); setActionError(t('activity.reply.actionFailed', { defaultValue: 'Your reply could not be sent. Try again.' })); restoreActionFocus(); } finally { setReplyingId(null); } }; const acknowledgeAttention = async (item: NeedsYouItem, errorKey: 'activity.mention.actionFailed' | 'activity.handoff.actionFailed') => { if (acknowledgingAttentionId) return; const restoreActionFocus = captureActionFocus(item); setAcknowledgingAttentionId(item.id); setActionError(null); setActionErrorItemId(null); try { const token = localStorage.getItem('token'); const response = await axios.post<{ success?: boolean }>( `/api/activity/${item.attentionItemId || item.id}/acknowledge`, {}, { headers: { 'x-auth-token': token ?? '' } }, ); if (!response.data?.success) throw new Error('Activity handling failed'); notifyAttentionChanged(); setReloadKey((value) => value + 1); } catch { const message = t(errorKey, { defaultValue: 'That attention item could not be marked handled. Try again.' }); setActionErrorItemId(item.id); setActionError(message); restoreActionFocus(); } finally { setAcknowledgingAttentionId(null); } }; const acknowledgeMention = (item: NeedsYouItem) => acknowledgeAttention(item, 'activity.mention.actionFailed'); const markHandoffHandled = (item: NeedsYouItem) => acknowledgeAttention(item, 'activity.handoff.actionFailed'); return (
sum + group.lines.length, 0) })}`} hint={t('activity.subtitle')} hintLabel={t('activity.about')} />
{(['today', '7d'] as ActivityWindow[]).map((value) => ( ))}
{ if (event.key === 'Escape' && scopeMenuOpen) { event.preventDefault(); event.stopPropagation(); // This menu restores focus; the global Escape handler blurs it. setScopeMenuOpen(false); scopeMenuButtonRef.current?.focus(); } }}> {scopedPods.slice(0, 2).map((pod) => ( ))} {scopedPods.length > 2 && } {scopeMenuOpen &&
{scopedPods.slice(2).map((pod) => ( ))}
}
{loading &&
} {!loading && error &&
{error}
} {!loading && !error && recap && ( <> {/* The composer stays hidden while step 1 is open — nobody to wake — and returns the moment an agent exists. */} {!startSteps.includes('hire') && (
{t('activity.compose.podLabel')}
{composeMenuOpen &&
{scopedPods.map((pod, index) => ( ))}
}