import React, { useEffect, useMemo, useState } from 'react'; import CloseIcon from '@mui/icons-material/Close'; import ViewSidebarOutlinedIcon from '@mui/icons-material/ViewSidebarOutlined'; import { useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import V2Avatar from './V2Avatar'; import { UseV2PodDetailResult, V2Agent } from '../hooks/useV2PodDetail'; import { useV2Api } from '../hooks/useV2Api'; import { V2AttentionItem } from '../hooks/useV2PodAttention'; import V2Lightbox from './V2Lightbox'; import V2Segmented from './V2Segmented'; import { ShellGlyph, ShellGlyphName } from '../icons/glyphs'; import { getSignedAttachmentUrl } from '../../utils/signedAttachmentUrl'; import { buildAgentUsername, rawAgentName } from '../utils/threadAgentIdentity'; interface V2InspectorProps { detail: UseV2PodDetailResult; currentUserId?: string; onPodChanged?: () => Promise; attentionItems?: V2AttentionItem[]; attentionCount?: number | null; onClose?: () => void; onOpenInvite?: () => void; } interface ArtifactRow { id: string; fileName: string; name: string; kind: 'image' | 'page' | 'doc'; createdAt: string | null } interface ArtifactsPage { items?: ArtifactRow[]; total?: number } const extOf = (name: string): string => { const m = /\.([a-z0-9]{1,5})$/i.exec(name || ''); return m ? m[1].toUpperCase() : 'FILE'; }; const whenLabel = (value: string | null | undefined): string => { if (!value) return ''; const minutes = Math.floor(Math.max(0, Date.now() - new Date(value).getTime()) / 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`; const days = Math.floor(hours / 24); return days < 30 ? `${days}d` : `${Math.floor(days / 30)}mo`; }; type InspectorView = 'pod' | 'threads'; interface ThreadRow { rootId: string; author: string; snippet: string; lastAt: string | null } /** * The pod's threads, newest activity first, from the messages already loaded. * A root outside the loaded window has no text to show, so it waits until * history reaches it rather than rendering as a blank row. */ export const threadRowsFrom = (messages: UseV2PodDetailResult['messages']): ThreadRow[] => { const byId = new Map(messages.map((message) => [String(message.id), message])); const lastAt = new Map(); messages.forEach((message) => { if (message.thread_root_id === null || message.thread_root_id === undefined) return; const rootId = String(message.thread_root_id); const at = message.created_at || message.createdAt || ''; if (!lastAt.has(rootId) || at > (lastAt.get(rootId) || '')) lastAt.set(rootId, at); }); const rows: ThreadRow[] = []; lastAt.forEach((at, rootId) => { const root = byId.get(rootId); if (!root) return; const author = root.user?.username || (typeof root.userId === 'object' ? root.userId?.username : undefined) || ''; const snippet = String(root.content || '').replace(/\s+/g, ' ').trim().slice(0, 120); rows.push({ rootId, author, snippet, lastAt: at || null }); }); return rows.sort((a, b) => (b.lastAt || '').localeCompare(a.lastAt || '')); }; interface TaskItem { taskId: string; title: string; status: string; assignee?: string; updatedAt?: string; } const isWorkingTask = (task: TaskItem): boolean => ( task.status === 'claimed' || task.status === 'in_progress' ); // D215 (TASK-691): the panel folds. Each group is one 44px row at rest and // opens on demand; what a person opened stays open for the session. export type InspectorGroup = 'needs-you' | 'agents' | 'files' | 'join'; export const INSPECTOR_OPEN_KEY = 'v2:inspector.openGroups'; const readOpenGroups = (): Set => { try { const raw = sessionStorage.getItem(INSPECTOR_OPEN_KEY); return new Set(raw ? (JSON.parse(raw) as InspectorGroup[]) : []); } catch { return new Set(); } }; const writeOpenGroups = (groups: Set) => { try { sessionStorage.setItem(INSPECTOR_OPEN_KEY, JSON.stringify([...groups])); } catch { // Session storage unavailable: the panel simply opens folded next time. } }; const labelForAgent = (agent: V2Agent, fallback: string): string => { // The roster's displayName already went through resolveAgentDisplayLabel on // the backend for agents with a User row. This chain stops there on purpose: // instanceId and username are raw identifiers, which TASK-551 reports. A // profile name may be an older generated ID, so it is only a fallback. // agentName is never a label: it names the runtime or the agent type. Each // caller passes its own generic fallback, so no label renders blank. A // profile name equal to the agentName is that same name, so it is skipped. const profileName = agent.profile?.displayName || ''; const profileLabel = profileName === (agent.agentName || '') ? '' : profileName; return agent.displayName || profileLabel || fallback; }; const matchesAgent = (agent: V2Agent, value?: string): boolean => { const candidate = (value || '').trim().toLowerCase(); if (!candidate) return false; // Matching never uses the generic label: '' keeps it out of the comparison. return candidate === (agent.agentName || '').toLowerCase() || candidate === labelForAgent(agent, '').toLowerCase() || candidate === (agent.profile?.displayName || '').toLowerCase() || candidate === (agent.instanceId || '').toLowerCase(); }; // An attention actor is an agentName. Match it the way agentState does, then // name that agent. An unmatched actor gets the generic label, never its raw name. const labelForAttentionActor = (agents: V2Agent[], actorName: string, fallback: string): string => { const agent = agents.find((candidate) => matchesAgent(candidate, actorName)); return agent ? labelForAgent(agent, fallback) : fallback; }; const agentState = ( agent: V2Agent, attention: V2AttentionItem[], tasks: TaskItem[], ): { kind: 'needs-you' | 'working' | 'idle'; title?: string } => { const request = attention.find((item) => matchesAgent(agent, item.actorName)); if (request) return { kind: 'needs-you', title: request.title }; const task = tasks.find((item) => isWorkingTask(item) && matchesAgent(agent, item.assignee)); if (task) return { kind: 'working', title: task.title }; if (agent.status === 'working') return { kind: 'working' }; return { kind: 'idle' }; }; const V2Inspector: React.FC = ({ detail, currentUserId, onPodChanged, attentionItems = [], attentionCount = null, onClose, onOpenInvite }) => { const { pod, agents } = detail; const api = useV2Api(); const navigate = useNavigate(); const { t } = useTranslation(); const [tasks, setTasks] = useState([]); const [files, setFiles] = useState([]); const [filesTotal, setFilesTotal] = useState(0); const [lightbox, setLightbox] = useState(null); const [joinPolicy, setJoinPolicy] = useState<'open' | 'invite-only'>('open'); const [policyBusy, setPolicyBusy] = useState(false); const [policyError, setPolicyError] = useState(null); const [openGroups, setOpenGroups] = useState>(() => readOpenGroups()); // D215 (TASK-734): the panel head switches between the pod and its threads. const [view, setView] = useState('pod'); useEffect(() => { setView('pod'); }, [pod?._id]); const threads = useMemo(() => threadRowsFrom(detail.messages || []), [detail.messages]); const toggleGroup = (group: InspectorGroup) => { setOpenGroups((current) => { const next = new Set(current); if (next.has(group)) next.delete(group); else next.add(group); writeOpenGroups(next); return next; }); }; useEffect(() => { setJoinPolicy(pod?.joinPolicy === 'invite-only' ? 'invite-only' : 'open'); setPolicyError(null); }, [pod?._id, pod?.joinPolicy]); const attention = useMemo( () => attentionItems.filter((item) => item.podId === pod?._id), [attentionItems, pod?._id], ); useEffect(() => { if (!pod?._id) { setTasks([]); return undefined; } let active = true; api.get<{ tasks?: TaskItem[] }>(`/api/v1/tasks/${encodeURIComponent(pod._id)}`) .then((data) => { if (active) setTasks(Array.isArray(data?.tasks) ? data.tasks : []); }) .catch(() => { if (active) setTasks([]); }); return () => { active = false; }; }, [api, pod?._id]); // Files pane (PR 5, 66366 §4): the same /api/artifacts query with podId fixed. useEffect(() => { if (!pod?._id) { setFiles([]); setFilesTotal(0); return undefined; } let active = true; api.get(`/api/artifacts?podId=${encodeURIComponent(pod._id)}&limit=5`) .then((data) => { if (!active) return; setFiles(Array.isArray(data?.items) ? data.items : []); setFilesTotal(typeof data?.total === 'number' ? data.total : (Array.isArray(data?.items) ? data.items.length : 0)); }) .catch(() => { if (active) { setFiles([]); setFilesTotal(0); } }); return () => { active = false; }; }, [api, pod?._id]); if (!pod) return null; const isOwner = Boolean(currentUserId && pod.createdBy?._id === currentUserId); const changeJoinPolicy = async (next: 'open' | 'invite-only') => { if (!isOwner || policyBusy || next === joinPolicy) return; setPolicyBusy(true); setPolicyError(null); try { await api.patch(`/api/pods/${encodeURIComponent(pod._id)}/join-policy`, { joinPolicy: next }); setJoinPolicy(next); await Promise.all([detail.refresh(), onPodChanged?.()]); } catch (requestError) { const data = (requestError as { response?: { data?: { message?: string; error?: string } } })?.response?.data; setPolicyError(data?.message || data?.error || t('inspector.workspace.joinPolicyError')); } finally { setPolicyBusy(false); } }; const openAttention = (item: V2AttentionItem) => { const target = item.threadRootId || item.messageId; navigate(`/v2/pods/${item.podId}${target ? `#message-${target}` : ''}`); }; const agentStatus = (state: ReturnType): string => { if (state.kind === 'needs-you') return t('inspector.workspace.agentStatus.needsYou', { title: state.title }); if (state.kind === 'working' && state.title) return t('inspector.workspace.agentStatus.workingTask', { title: state.title }); if (state.kind === 'working') return t('inspector.workspace.agentStatus.working'); return t('inspector.workspace.agentStatus.idle'); }; const workingAgents = agents .filter((agent) => agentState(agent, attention, tasks).kind === 'working') .map((agent) => labelForAgent(agent, t('common.agent'))); const emptyAttentionCopy = workingAgents.length > 0 ? t('inspector.workspace.nothingWorking', { agents: workingAgents.join(' and '), count: workingAgents.length }) : t('inspector.workspace.nothingOpen'); const needsYouTotal = attentionCount ?? attention.length; const renderGroup = ( group: InspectorGroup, icon: ShellGlyphName, label: string, count: number | null, content: React.ReactNode, extra?: React.ReactNode, ) => { const open = openGroups.has(group); const headId = `workspace-inspector-${group}`; return (

{open &&
{content}
}
); }; return ( ); }; export default V2Inspector;