N times. If there are no slide divs, the deck
- // is empty (e.g.
) — show the parity placeholder rather
- // than rendering the empty canvas with "1 / 0" pagination.
- const slideCount = (text.match(/
{ cancelled = true; };
- }, [artifact.fileName, t]);
- if (busy) return
{t('inspector.preview.renderingPowerpoint')};
- if (error) return
{t('inspector.preview.couldNotPreview', { error })};
- // Empty string == zero-slide deck (set above); render the same placeholder
- // shape DocxPreview/XlsxPreview use for genuinely-empty deliverables.
- if (html === '') return
{t('inspector.preview.emptyDeck')};
- if (!html) return null;
- return (
-
-
-
- );
-};
-
-const EmbedPreview: React.FC<{ src: string; title: string; allow?: string }> = ({ src, title, allow }) => (
-
-
-
-);
-
-const ArtifactPreview: React.FC<{ artifact: PreviewArtifact }> = ({ artifact }) => {
- const { kind } = artifact;
- // File previews — gated on having a fileName (i.e. it came from /api/uploads)
- if (artifact.fileName) {
- if (kind === 'image') return
;
- if (kind === 'pdf') return
;
- if (kind === 'md') return
;
- if (kind === 'txt') return
;
- if (kind === 'json') return
;
- if (kind === 'csv') return
;
- if (kind === 'docx') return
;
- if (kind === 'xlsx') return
;
- if (kind === 'pptx') return
;
- return null; // doc / xls / ppt / odt / ods / odp / zip — Open only
- }
- // URL artifacts — embed where the vendor allows iframe.
- const embed = embedUrlFor(kind, artifact.url);
- if (embed) {
- const allow = kind === 'youtube' ? 'accelerometer; encrypted-media; gyroscope; picture-in-picture' : undefined;
- return
;
- }
- return null;
-};
-
-interface RunStateCounts {
- blocked: number;
- inProgress: number;
- complete: number;
- pending: number;
-}
-
-const Icon = ({ d, size = 14 }: { d: string; size?: number }) => (
-
-);
-
-// Shared with V2PodChat's author map — see agentKey.ts for why the key is
-// composite (the 'default'-instanceId collision that sent every member click
-// to one seat's profile).
-const agentKeyOf = (agent: V2Agent): string => agentKeyFor(agent);
-
-// Driver badge for the agent runtime — letterform monogram + label, no emoji.
-// `runtimeType` carries identity (codex / claude-code / openclaw / etc.);
-// `host` carries location ('cloud' = Commonly-managed, 'byo' = user runs it
-// themselves, ADR-005-style — laptop or their own server). The two are
-// orthogonal: a codex agent can be cloud-hosted or BYO and the badge shows
-// the same identity, with a small "BYO" tag rendered alongside when host=byo.
-// Cloud is the implicit default and gets no decoration to keep chrome quiet.
-//
-// Backwards compat: legacy `local-cli` + `wrappedCli` (pre-2026-05-04 CLI
-// attach) is normalized server-side by sanitizeRuntimeConfig — but we still
-// branch on it here as defense-in-depth in case the frontend sees an
-// unmigrated payload from an older backend.
-const resolveRuntimeBadge = (
- agent: V2Agent,
-): { mono: string; label: string; isByo: boolean } | null => {
- const type = agent.runtime?.runtimeType;
- const wrappedCli = agent.runtime?.wrappedCli;
- const host = agent.runtime?.host;
- const isByo = host === 'byo';
-
- // Identity → mono + label.
- let identity: { mono: string; label: string } | null = null;
- if (type === 'moltbot') identity = { mono: 'OC', label: 'OpenClaw' };
- else if (type === 'codex' || (type === 'local-cli' && wrappedCli === 'codex')) identity = { mono: 'CX', label: 'Codex' };
- else if (type === 'claude-code' || (type === 'local-cli' && wrappedCli === 'claude')) identity = { mono: 'CC', label: 'Claude Code' };
- else if (type === 'gemini' || (type === 'local-cli' && wrappedCli === 'gemini')) identity = { mono: 'GE', label: 'Gemini' };
- else if (type === 'webhook') identity = { mono: 'WH', label: 'Webhook' };
- else if (type === 'internal') identity = { mono: 'NA', label: 'Native' };
- else if (type === 'local-cli') identity = { mono: 'LC', label: 'Local CLI' };
-
- if (!identity) return null;
- return { ...identity, isByo: isByo || type === 'local-cli' };
-};
-
-const memberRoleKey = (
- member: { _id?: string; isBot?: boolean },
- ownerId: string | undefined,
- isAgent: boolean,
-): 'owner' | 'human' | 'aiAgent' => {
- if (ownerId && member._id === ownerId) return 'owner';
- if (isAgent || member.isBot) return 'aiAgent';
- return 'human';
-};
-
-const V2PodInspector: React.FC
= ({
- detail, podsState, view, onClose, onOpenMember, onOpenArtifact, onBack, onOpenInvite,
- pendingOpenFileName, onPendingOpenFileNameConsumed,
-}) => {
- const { t } = useTranslation();
- const { pod, members, agents } = detail;
- const api = useV2Api();
- const navigate = useNavigate();
- const { currentUser } = useAuth();
- const [agentTasks, setAgentTasks] = useState({});
- const [runState, setRunState] = useState({ blocked: 0, inProgress: 0, complete: 0, pending: 0 });
- const [recentTasks, setRecentTasks] = useState([]);
- // Bumped by task_updated socket events so the tab count / run state /
- // recent list track the board live. Without it, tasks fetched once per
- // pod-open — a task created mid-conversation ("it's task #4") sat next to
- // a tab still saying "Tasks 3" until a full reload (#916).
- const [taskRefresh, setTaskRefresh] = useState(0);
- const { socket, connected } = useSocket();
-
- useEffect(() => {
- const podId = pod?._id;
- if (!podId || !socket || !connected) return undefined;
- const onTaskUpdated = (payload: { podId?: string } | null) => {
- if (!payload || (payload.podId && payload.podId !== podId)) return;
- setTaskRefresh((n) => n + 1);
- };
- socket.on('task_updated', onTaskUpdated);
- return () => { socket.off('task_updated', onTaskUpdated); };
- }, [pod?._id, socket, connected]);
-
- // ADR-020 D3: pending approvals from the ApprovalAction rows — the durable
- // index. Card MESSAGES retire under the 30-day retention window; a flagged
- // approval must stay findable regardless of how far the card scrolled
- // (Sam, 2026-08-13). Refetches on the same socket event that patches chat.
- interface PendingApproval {
- approvalId: string;
- summary: string;
- agentName: string;
- ownerUserId: string;
- expiresAt?: string;
- createdAt?: string;
- }
- const [pendingApprovals, setPendingApprovals] = useState([]);
- const [approvalRefresh, setApprovalRefresh] = useState(0);
- const [decidingApprovalId, setDecidingApprovalId] = useState(null);
-
- useEffect(() => {
- const podId = pod?._id;
- if (!podId) { setPendingApprovals([]); return undefined; }
- let cancelled = false;
- (async () => {
- try {
- const data = await api.get<{ approvals: PendingApproval[] }>(
- `/api/approvals/pending?podId=${encodeURIComponent(podId)}`,
- );
- if (!cancelled) setPendingApprovals(data.approvals || []);
- } catch {
- if (!cancelled) setPendingApprovals([]);
- }
- })();
- return () => { cancelled = true; };
- }, [pod?._id, api, approvalRefresh]);
-
- useEffect(() => {
- const podId = pod?._id;
- if (!podId || !socket || !connected) return undefined;
- const onCardUpdated = (payload: { podId?: string } | null) => {
- if (!payload || (payload.podId && payload.podId !== podId)) return;
- setApprovalRefresh((n) => n + 1);
- };
- // A NEW proposal arrives as a newMessage carrying a card payload — not a
- // messageCardUpdated — so without this the index missed fresh proposals
- // until reload (caught in the round-2 live verify).
- const onNewMessage = (message: { pod_id?: string; podId?: string; payload?: { kind?: string } | null } | null) => {
- if (!message) return;
- const msgPodId = message.pod_id || message.podId;
- if (msgPodId && msgPodId !== podId) return;
- if (message.payload?.kind === 'approval-card') setApprovalRefresh((n) => n + 1);
- };
- socket.on('messageCardUpdated', onCardUpdated);
- socket.on('newMessage', onNewMessage);
- return () => {
- socket.off('messageCardUpdated', onCardUpdated);
- socket.off('newMessage', onNewMessage);
- };
- }, [pod?._id, socket, connected]);
-
- const decideApproval = async (approvalId: string, decision: 'approved' | 'declined') => {
- if (decidingApprovalId) return;
- setDecidingApprovalId(approvalId);
- try {
- await api.post(`/api/approvals/${encodeURIComponent(approvalId)}/resolve`, { decision });
- } catch {
- // The refetch below shows the authoritative state either way.
- } finally {
- setDecidingApprovalId(null);
- setApprovalRefresh((n) => n + 1);
- }
- };
- const [privateError, setPrivateError] = useState(null);
- // ADR-001 §3.10 + Sprint B1: agent-DM pods involving the currently-inspected
- // agent. Surfaces clickable links so humans can observe agent↔agent
- // conversations happening between agents in their team pods (the §3.7
- // co-pod-member rule lets the viewer see those DMs even though they're
- // not a member). Keyed by agentKey to avoid stale state across selections.
- const [a2aDmsByAgent, setA2aDmsByAgent] = useState>>({});
- const [announcements, setAnnouncements] = useState([]);
- const [externalLinks, setExternalLinks] = useState([]);
- const [podFiles, setPodFiles] = useState([]);
- const [tab, setTab] = useState<'overview' | 'members' | 'tasks' | 'manage'>('overview');
- const [addLinkOpen, setAddLinkOpen] = useState(false);
- const [addLinkUrl, setAddLinkUrl] = useState('');
- const [addLinkBusy, setAddLinkBusy] = useState(false);
- const [addLinkError, setAddLinkError] = useState(null);
- // Invite modal lives on V2Layout — see `onOpenInvite` prop. The members
- // section "+ Invite" button delegates upward so the chat header invite
- // icon and the inspector button share one modal instance.
-
- // Map agent username (`openclaw-nova`) → agent record so we can look up by
- // either instance id or full username when chat clicks come in.
- const agentByKey = useMemo(() => {
- const map = new Map();
- agents.forEach((a) => {
- const id = agentKeyOf(a);
- if (id) map.set(id, a);
- const u = `${a.agentName}-${a.instanceId || 'default'}`;
- map.set(u, a);
- });
- return map;
- }, [agents]);
-
- // Map agent username (`openclaw-nova-demo`) → per-installation displayName
- // ("Nova"). Mirrors AgentIdentityService.buildAgentUsername. Surfaces
- // through the "Created by …" inspector header so an auto-created
- // agent-room reads "Created by Nova" instead of the raw bot username.
- const agentDisplayByUsername = useMemo(() => {
- const map = new Map();
- agents.forEach((a) => {
- const rawName = ((a as { name?: string; agentName?: string }).name || a.agentName || '').toLowerCase();
- const inst = (a.instanceId || '').toLowerCase();
- const username = !inst || inst === 'default' || inst === rawName
- ? rawName
- : `${rawName}-${inst}`;
- const display = a.displayName || a.profile?.displayName;
- if (username && display) map.set(username, display);
- });
- return map;
- }, [agents]);
- const createdByDisplay = useMemo(() => {
- // `pod` can be null during initial detail-fetch — guard so the
- // memo doesn't throw before render. (The Inspector renders a loading
- // shell when `pod` is null; we just need the memo to not crash.)
- const raw = pod?.createdBy?.username || '';
- return agentDisplayByUsername.get(raw.toLowerCase()) || raw || t('inspector.unknown');
- }, [pod?.createdBy?.username, agentDisplayByUsername, t]);
-
- // Set of usernames that map to an installed agent — used to filter the
- // members[] list down to actual humans. The backend's `User.isBot` flag
- // isn't reliably set on agent User rows in the wire payload, so we can't
- // trust `member.isBot` alone. Mirrors AgentIdentityService.buildAgentUsername.
- const agentUsernames = useMemo(() => {
- const set = new Set();
- agents.forEach((a) => {
- const rawName = ((a as { name?: string; agentName?: string }).name || a.agentName || '').toLowerCase();
- const inst = (a.instanceId || '').toLowerCase();
- const username = !inst || inst === 'default' || inst === rawName
- ? rawName
- : `${rawName}-${inst}`;
- if (username) set.add(username);
- });
- return set;
- }, [agents]);
-
- const humanMembers = useMemo(
- () => members.filter((m) => {
- if (m.isBot) return false;
- const u = (m.username || '').toLowerCase();
- return u && !agentUsernames.has(u);
- }),
- [members, agentUsernames],
- );
-
- useEffect(() => {
- const podId = pod?._id;
- if (!podId) {
- setAgentTasks({});
- setRunState({ blocked: 0, inProgress: 0, complete: 0, pending: 0 });
- return;
- }
- let cancelled = false;
- (async () => {
- try {
- const data = await api.get(`/api/v1/tasks/${podId}`);
- const tasks = data.tasks || [];
- // Per-agent "Working on" — first matching active task.
- const map: AgentTaskMap = {};
- agents.forEach((a) => {
- const key = agentKeyOf(a);
- const match = tasks.find((t) => {
- const assignee = (t.assignee || '').toLowerCase();
- const isActive = t.status === 'pending' || t.status === 'claimed' || t.status === 'in_progress';
- if (!isActive) return false;
- return assignee && (
- assignee === (a.instanceId || '').toLowerCase()
- || assignee === (a.agentName || '').toLowerCase()
- || assignee === (a.displayName || '').toLowerCase()
- );
- });
- map[key] = match ? { taskId: match.taskId, title: match.title, status: match.status } : null;
- });
- // Run-state pill counts.
- const counts: RunStateCounts = { blocked: 0, inProgress: 0, complete: 0, pending: 0 };
- tasks.forEach((t) => {
- switch (t.status) {
- case 'blocked':
- counts.blocked += 1;
- break;
- case 'claimed':
- case 'in_progress':
- counts.inProgress += 1;
- break;
- case 'done':
- case 'completed':
- counts.complete += 1;
- break;
- case 'pending':
- counts.pending += 1;
- break;
- default:
- break;
- }
- });
- // Surface the 8 most recently updated tasks for the Tasks tab.
- // Sorted so active work surfaces first (in-progress/claimed/pending
- // before blocked, completed last), then by updatedAt desc within group.
- const activeRank: Record = {
- in_progress: 0, claimed: 0, pending: 1, blocked: 2, done: 3, completed: 3,
- };
- const sorted = [...tasks].sort((a, b) => {
- const ra = activeRank[a.status] ?? 4;
- const rb = activeRank[b.status] ?? 4;
- if (ra !== rb) return ra - rb;
- const ua = a.updatedAt ? new Date(a.updatedAt).getTime() : 0;
- const ub = b.updatedAt ? new Date(b.updatedAt).getTime() : 0;
- return ub - ua;
- }).slice(0, 8);
- if (!cancelled) {
- setAgentTasks(map);
- setRunState(counts);
- setRecentTasks(sorted);
- }
- } catch {
- if (!cancelled) {
- setAgentTasks({});
- setRunState({ blocked: 0, inProgress: 0, complete: 0, pending: 0 });
- setRecentTasks([]);
- }
- }
- })();
- return () => { cancelled = true; };
- }, [pod?._id, agents, api, taskRefresh]);
-
- useEffect(() => {
- const podId = pod?._id;
- if (!podId) {
- setAnnouncements([]);
- setExternalLinks([]);
- setPodFiles([]);
- return;
- }
- let cancelled = false;
- (async () => {
- const [announcementResult, linksResult, filesResult] = await Promise.allSettled([
- api.get(`/api/pods/${podId}/announcements`),
- api.get(`/api/pods/${podId}/external-links`),
- api.get(`/api/pods/${podId}/files`),
- ]);
- if (cancelled) return;
- setAnnouncements(announcementResult.status === 'fulfilled' && Array.isArray(announcementResult.value) ? announcementResult.value : []);
- setExternalLinks(linksResult.status === 'fulfilled' && Array.isArray(linksResult.value) ? linksResult.value : []);
- setPodFiles(filesResult.status === 'fulfilled' && Array.isArray(filesResult.value) ? filesResult.value : []);
- })();
- return () => { cancelled = true; };
- }, [pod?._id, api]);
-
- // Resolve pending file-name → artifactId once podFiles is loaded. The
- // chat file pill sets pendingOpenFileName via V2Layout; we match on
- // either the ObjectStore key (`fileName`) or the human-facing original
- // filename so static `[[file:foo.md]]` tokens that share an originalName
- // with a real upload still route correctly.
- useEffect(() => {
- if (!pendingOpenFileName) return;
- if (podFiles.length === 0) return;
- const match = podFiles.find((f: { fileName?: string; originalName?: string }) => (
- f.fileName === pendingOpenFileName || f.originalName === pendingOpenFileName
- )) as { _id?: string } | undefined;
- if (match?._id) {
- onOpenArtifact(`file-${match._id}`);
- }
- onPendingOpenFileNameConsumed?.();
- }, [pendingOpenFileName, podFiles, onOpenArtifact, onPendingOpenFileNameConsumed]);
-
- // Sprint B1 — fetch the agent's a2a-DM list when a member is selected.
- // MUST live above the `if (!pod) return` early return below; otherwise
- // hooks order shifts when pod transitions null→loaded and React errors
- // out with #310 ("Rendered fewer hooks than expected"). The ref-backed
- // cache key includes pod._id so cross-pod inspector loads re-fetch.
- const a2aDmsFetchedRef = useRef>(new Set());
- useEffect(() => {
- if (!pod || view.kind !== 'member') return;
- const agent = agentByKey.get(view.agentKey);
- if (!agent) return;
- const key = `${pod._id}:${agentKeyOf(agent)}`;
- if (a2aDmsFetchedRef.current.has(key)) return;
- a2aDmsFetchedRef.current.add(key);
- let cancelled = false;
- (async () => {
- try {
- const url = `/api/registry/pods/${pod._id}/agents/${encodeURIComponent(agent.agentName)}/a2a-dms?instanceId=${encodeURIComponent(agent.instanceId || 'default')}`;
- const data = await api.get<{ a2aDms?: Array<{ podId: string; name: string; otherMember: { displayName: string; isBot: boolean } | null; updatedAt?: string }> }>(url);
- const list = data?.a2aDms || [];
- if (!cancelled) {
- setA2aDmsByAgent((prev) => ({ ...prev, [key]: list }));
- }
- } catch (err) {
- // Defensive: any error renders the section empty rather than
- // blocking the detail panel. Allow retry by clearing the ref
- // entry so a subsequent re-select re-attempts the fetch.
- if (!cancelled) {
- a2aDmsFetchedRef.current.delete(key);
- setA2aDmsByAgent((prev) => ({ ...prev, [key]: [] }));
- }
- }
- })();
- return () => { cancelled = true; };
- }, [view, pod, agentByKey, api]);
-
- if (!pod) return ;
-
- const isAgentRoom = pod.type === 'agent-room';
- const humanCount = humanMembers.length;
- const agentCount = agents.length;
- const created = pod.createdAt ? new Date(pod.createdAt).toLocaleDateString([], {
- month: 'short', day: 'numeric',
- }) : t('inspector.unknown');
- const ownerId = pod.createdBy?._id;
-
- const openPrivatePod = async (agent: V2Agent) => {
- setPrivateError(null);
- try {
- const data = await api.post<{ room?: { _id?: string } }>('/api/agents/runtime/room', {
- agentName: agent.agentName,
- instanceId: agent.instanceId || 'default',
- podId: pod._id,
- });
- const roomId = data.room?._id;
- if (roomId) navigate(`/v2/pods/${roomId}`);
- else setPrivateError(t('inspector.errors.privatePodNotOpenedForAgent'));
- } catch (err) {
- const e = err as { response?: { data?: { message?: string; error?: string; msg?: string } }; message?: string };
- setPrivateError(e.response?.data?.message || e.response?.data?.error || e.response?.data?.msg || e.message || t('inspector.errors.privatePodNotOpened'));
- }
- };
-
- const handleDeletePod = async () => {
- if (!podsState || !pod) return;
- const confirmed = window.confirm(t('inspector.confirmDeletePod', { name: pod.name }));
- if (!confirmed) return;
- const deleted = await podsState.deletePod(pod._id);
- if (deleted) navigate('/v2', { replace: true });
- };
-
- // ------------------------------------------------------------------
- // NOW — first agent with an active task. Hidden if nothing is in flight.
- // ------------------------------------------------------------------
- const nowAgent = agents.find((a) => {
- const key = agentKeyOf(a);
- return !!agentTasks[key];
- });
- const nowTask = nowAgent ? agentTasks[agentKeyOf(nowAgent)] : null;
- const nowSection = nowAgent && nowTask && (
-
-
-
{t('inspector.now')}
-
-
- {(nowAgent.profile?.displayName || nowAgent.displayName || nowAgent.agentName)}
- {' · '}
- {nowTask.title}
-
-
- {t('inspector.statusLabel', { status: nowTask.status.replace('_', ' ') })}
-
-
-
- );
-
- // ------------------------------------------------------------------
- // PROGRESS — overall pod task completion. Hidden when the pod has
- // no tracked tasks (avoids implying progress where there isn't any).
- // ------------------------------------------------------------------
- const totalTasks = runState.complete + runState.inProgress + runState.pending + runState.blocked;
- const progressPct = totalTasks > 0 ? Math.round((runState.complete / totalTasks) * 100) : 0;
- const progressSection = totalTasks > 0 && (
-
- {t('inspector.progress.title')}
-
-
- {t('inspector.progress.stat', { complete: runState.complete, total: totalTasks, count: totalTasks })}
-
- {progressPct}%
-
-
-
- );
-
- // ------------------------------------------------------------------
- // OVERVIEW — Goal / Artifacts
- // ------------------------------------------------------------------
- const goalSection = (
-
- {t('inspector.goal.title')}
-
- {pod.description?.trim() || {t('inspector.goal.empty')}}
-
-
- );
-
- // Files come last so the freshest URL artifact (added via "+ Add") still
- // sits at the top — matches the user's expectation that the row they just
- // pasted is visible without scrolling. Within each source, server returns
- // newest-first.
- const artifactItems: Array<{ id: string; kind: string; title: string; subtitle?: string; url?: string; fileName?: string }> = [
- ...announcements.map((a) => ({
- id: `ann-${a._id}`,
- kind: 'Announcement',
- title: a.title || a.content || t('inspector.untitledAnnouncement'),
- })),
- ...externalLinks.map((l) => ({
- id: `link-${l._id}`,
- kind: l.type || 'other_link',
- title: l.name || l.url || t('inspector.externalLink'),
- subtitle: l.url,
- url: l.url,
- })),
- ...podFiles.map((f) => ({
- id: `file-${f._id}`,
- kind: fileKind(f.originalName, f.contentType),
- title: f.originalName || f.fileName,
- subtitle: f.contentType,
- // No `url` here — files require a signed-URL mint. The detail view's
- // Open button calls getSignedAttachmentUrl(`/api/uploads/${fileName}`).
- fileName: f.fileName,
- })),
- ];
-
- const handleAddLinkSubmit = async () => {
- const url = addLinkUrl.trim();
- if (!url || !pod) return;
- setAddLinkBusy(true);
- setAddLinkError(null);
- try {
- const created = await api.post('/api/pods/external-link', {
- podId: pod._id,
- type: 'auto',
- url,
- });
- setExternalLinks((prev) => [created, ...prev]);
- setAddLinkUrl('');
- setAddLinkOpen(false);
- } catch (err) {
- const e = err as { response?: { data?: { message?: string } }; message?: string };
- setAddLinkError(e.response?.data?.message || e.message || t('inspector.errors.couldNotAddLink'));
- } finally {
- setAddLinkBusy(false);
- }
- };
-
- const artifactsSection = (
-
-
-
{t('inspector.artifacts.title')}
-
-
- {addLinkOpen && (
-
-
setAddLinkUrl(e.target.value)}
- onKeyDown={(e) => {
- if (e.key === 'Enter' && !addLinkBusy && addLinkUrl.trim()) {
- e.preventDefault();
- void handleAddLinkSubmit();
- }
- }}
- placeholder={t('inspector.artifacts.urlPlaceholder')}
- autoFocus
- disabled={addLinkBusy}
- style={{
- width: '100%',
- padding: '8px 9px',
- border: '1px solid var(--v2-border)',
- borderRadius: 'var(--v2-radius-sm)',
- background: 'var(--v2-surface)',
- fontSize: 12,
- color: 'var(--v2-text-primary)',
- outline: 'none',
- }}
- />
- {addLinkError && (
-
{addLinkError}
- )}
-
-
-
-
-
- )}
- {artifactItems.length === 0 && !addLinkOpen ? (
- {t('inspector.artifacts.empty')}
- ) : artifactItems.length > 0 && (
-
- {artifactItems.map((a) => {
- const meta = artifactMeta(a.kind);
- return (
-
- );
- })}
-
- )}
-
- );
-
- const membersSection = !isAgentRoom && (
-
-
- {onOpenInvite && (
-
- )}
-
-
- {privateError && {privateError}
}
- {agents.map((agent) => {
- const name = agent.profile?.displayName || agent.displayName || agent.agentName;
- const key = agentKeyOf(agent);
- const isOnline = !!agent.lastHeartbeatAt
- && Date.now() - new Date(agent.lastHeartbeatAt).getTime() < 10 * 60 * 1000;
- const badge = resolveRuntimeBadge(agent);
- return (
-
- );
- })}
- {humanMembers.map((member) => {
- const roleKey = memberRoleKey(member, ownerId, false);
- return (
-
-
-
- {member.username}
- {t(`inspector.roles.${roleKey}`)}
-
-
- );
- })}
- {agents.length === 0 && humanCount === 0 && (
- {t('inspector.members.empty')}
- )}
-
- );
-
- // A room is deliberately 1:1, not a normal pod with an editable Members
- // tab. That does not mean its participants disappear: showing both here is
- // the confirmation that the "Talk to" action opened the intended private
- // conversation without reintroducing invite/manage controls.
- const roomParticipantsSection = isAgentRoom && (
-
- {t('inspector.members.participants')}
- {members.map((member) => {
- const roleKey = memberRoleKey(member, ownerId, Boolean(member.isBot));
- return (
-
-
-
- {member.username || t('inspector.unknownUser')}
- {t(`inspector.roles.${roleKey}`)}
-
-
- );
- })}
-
- );
-
- const runStateSection = (
-
- {t('inspector.runState.title')}
- {/* Same four buckets, same order, as the board's columns — the two
- surfaces sit one click apart, and folding `pending` into "In
- Progress" here read as "3 in progress" beside a board showing the
- same three tasks as Pending (#916). */}
-
-
- {t('inspector.runState.pendingCount', { count: runState.pending })}
- {t('inspector.runState.pending')}
-
-
- {t('inspector.runState.inProgressCount', { count: runState.inProgress })}
- {t('inspector.runState.inProgress')}
-
-
- {t('inspector.runState.blockedCount', { count: runState.blocked })}
- {t('inspector.runState.blocked')}
-
-
- {t('inspector.runState.completeCount', { count: runState.complete })}
- {t('inspector.runState.complete')}
-
-
- {(runState.blocked + runState.inProgress + runState.pending + runState.complete) > 0 ? (
- // Tasks exist — link to the v2-native board AND surface the recent
- // task list. (This used to exit the shell into the v1 Pod Tools
- // ChatRoom — the only v2 surface whose primary action left v2.)
- <>
-
- {recentTasks.length > 0 && (
-
-
{t('inspector.runState.recentTasks')}
- {recentTasks.map((task) => {
- const isDone = task.status === 'done' || task.status === 'completed';
- const isBlocked = task.status === 'blocked';
- const isPending = task.status === 'pending';
- const pillClass = isDone
- ? TASK_PILL_CLASS.complete
- : isBlocked
- ? TASK_PILL_CLASS.blocked
- : TASK_PILL_CLASS.progress;
- // A pending task labelled "Active" was the third distinct
- // status mapping on this rail (#916) — say what the board says.
- const pillLabel = isDone
- ? t('inspector.taskPill.done')
- : isBlocked
- ? t('inspector.taskPill.blocked')
- : isPending
- ? t('inspector.taskPill.pending')
- : t('inspector.taskPill.active');
- const assignee = task.assignee ? `@${task.assignee}` : null;
- return (
-
-
{task.title}
-
- {assignee && {assignee}}
- {pillLabel}
-
-
- );
- })}
-
- )}
- >
- ) : (
-
- {t('inspector.runState.empty')}
-
- )}
-
- );
-
- // ------------------------------------------------------------------
- // MEMBER DETAIL sub-page
- // ------------------------------------------------------------------
-
- const renderMemberDetail = (agentKey: string) => {
- const agent = agentByKey.get(agentKey);
- if (!agent) {
- return (
- {t('inspector.memberNotFound')}
- );
- }
- const name = agent.profile?.displayName || agent.displayName || agent.agentName;
- const isOnline = !!agent.lastHeartbeatAt
- && Date.now() - new Date(agent.lastHeartbeatAt).getTime() < 10 * 60 * 1000;
- const task = agentTasks[agentKeyOf(agent)];
- const purpose = agent.profile?.purpose;
- const specialties = agent.profile?.persona?.specialties || [];
- const dmable = isAgentDmable(agent);
- const badge = resolveRuntimeBadge(agent);
- return (
-
-
-
-
{name}
-
-
- {isOnline ? t('inspector.online') : t('inspector.idle')} · {agent.category ? `${agent.category} · ${t('inspector.aiAgent')}` : t('inspector.aiAgent')}
-
- {badge && (
-
-
- {badge.mono}
- {badge.label}
-
- {badge.isByo && (
-
- {t('inspector.runtime.byo')}
-
- )}
-
- )}
-
-
- {dmable && (
-
- )}
-
-
- {privateError &&
{privateError}
}
- {task && (
-
-
{t('inspector.detail.workingOn')}
-
{task.title}
-
- )}
- {purpose && (
-
-
{t('inspector.detail.purpose')}
-
{purpose}
-
- )}
- {specialties.length > 0 && (
-
-
{t('inspector.detail.specialties')}
-
- {specialties.map((s) => {s})}
-
-
- )}
- {(() => {
- const dmKey = `${pod._id}:${agentKeyOf(agent)}`;
- const list = a2aDmsByAgent[dmKey] || [];
- if (list.length === 0) return null;
- return (
-
-
{t('inspector.detail.directMessages')}
-
- {list.map((dm) => {
- const peer = dm.otherMember?.displayName || t('inspector.detail.peer');
- return (
-
- );
- })}
-
-
- );
- })()}
-
- );
- };
-
- // ------------------------------------------------------------------
- // ARTIFACT DETAIL sub-page
- // ------------------------------------------------------------------
- // File artifacts open via a signed-URL mint (no plain href to embed). URL
- // artifacts use safeHref. Click handler always preventDefault so we can
- // route both kinds through one button.
- const handleOpenArtifact = async (item: typeof artifactItems[0]) => {
- if (item.fileName) {
- const signed = await getSignedAttachmentUrl(`/api/uploads/${item.fileName}`);
- if (signed) window.open(signed, '_blank', 'noopener,noreferrer');
- return;
- }
- const href = safeHref(item.url);
- if (href) window.open(href, '_blank', 'noopener,noreferrer');
- };
-
- const renderArtifactDetail = (artifactId: string) => {
- const found = artifactItems.find((a) => a.id === artifactId);
- if (!found) {
- return {t('inspector.artifactNotFound')}
;
- }
- const meta = artifactMeta(found.kind);
- const openable = !!found.fileName || !!safeHref(found.url);
- return (
-
-
-
- {meta.icon}
-
-
{found.title}
-
{meta.label}
-
-
- {openable && (
-
-
-
- )}
- {found.subtitle && (
-
-
{found.fileName ? t('inspector.detail.type') : t('inspector.detail.source')}
-
{found.subtitle}
-
- )}
-
- );
- };
-
- // ------------------------------------------------------------------
- // SHELL
- // ------------------------------------------------------------------
- const isOverview = view.kind === 'overview';
- const heading = isOverview
- ? pod.name
- : view.kind === 'member'
- ? t('inspector.heading.member')
- : t('inspector.heading.artifact');
-
- return (
-
- );
-};
-
-export default V2PodInspector;
diff --git a/frontend/src/v2/utils/agentKey.ts b/frontend/src/v2/utils/agentKey.ts
index 5b612c2f23..784d2e08bf 100644
--- a/frontend/src/v2/utils/agentKey.ts
+++ b/frontend/src/v2/utils/agentKey.ts
@@ -1,6 +1,6 @@
/**
* The ONE key for "which pod member is this agent" across chat author clicks
- * and the inspector's member map/detail view.
+ * and the inspector's agent view.
*
* COMPOSITE, because instanceId alone is not an identity: every BYO fleet
* seat carries instanceId 'default', so the previous `instanceId || agentName`
@@ -8,7 +8,7 @@
* won, and clicking ANY agent's byline or member row rendered the same seat's
* profile (Sam, 2026-08-26: "clicking any agent's profile goes to fable").
*
- * Producers (V2PodChat author map) and consumers (V2PodInspector member map)
+ * Producers (V2PodChat author map) and consumers (V2Inspector agent view)
* MUST both import this — the collision existed precisely because each side
* derived its own key.
*/
diff --git a/frontend/src/v2/v2.css b/frontend/src/v2/v2.css
index b9e5c3de37..ff0cf02761 100644
--- a/frontend/src/v2/v2.css
+++ b/frontend/src/v2/v2.css
@@ -554,6 +554,10 @@ body.modern-ui.v2-canvas {
background: var(--v2-shell-bg);
}
+.v2-inspector-backdrop {
+ display: none;
+}
+
/* Mobile pods-drawer affordances — hidden on desktop, where the pods sidebar
is a permanent grid column. The <=760px media query (bottom of file) turns
these on and converts .v2-pods-aside into a slide-over overlay. Declared
@@ -5722,7 +5726,8 @@ body.modern-ui.v2-canvas {
transform: translateX(0);
}
- .v2-mobile-backdrop {
+ .v2-mobile-backdrop,
+ .v2-inspector-backdrop {
display: block;
position: fixed;
inset: 0;
@@ -10025,4 +10030,226 @@ body.modern-ui.v2-canvas {
right: 6px;
z-index: 1;
}
+
+ /* The inspector is a bottom sheet on phones, not a narrow desktop drawer. */
+ .v2-pane--inspector {
+ top: auto;
+ left: 0;
+ width: 100%;
+ max-height: min(78dvh, 680px);
+ border: 1px solid var(--v2-border);
+ border-bottom: 0;
+ border-radius: 6px 6px 0 0;
+ box-shadow: 0 -12px 32px rgba(17, 24, 39, 0.14);
+ }
+}
+
+/* ---------- Workspace inspector (TASK-129 PR 2) ----------
+ Three factual cards replace the old tabbed inspector. The route keeps the
+ same invite, agent-profile, board, and attachment exits without retaining
+ a second, legacy inspector surface. */
+
+.v2-workspace-inspector {
+ position: relative;
+ display: flex;
+ min-height: 0;
+ height: 100%;
+ flex-direction: column;
+ gap: 12px;
+ overflow-y: auto;
+ background: var(--v2-shell-bg);
+}
+
+/* The new inspector is a card stack on the shell ground, not a second pane
+ delineated by a desktop divider. Its cards provide the only borders. */
+.v2-pane--inspector {
+ border-left: 0;
+}
+
+.v2-workspace-inspector__card {
+ min-width: 0;
+ border: 1px solid var(--v2-border);
+ border-radius: 6px;
+ background: var(--v2-surface);
+ padding: 12px;
+}
+
+.v2-workspace-inspector__label {
+ margin: 0 0 10px;
+ color: var(--v2-text-muted);
+ font: 500 11px/14px var(--v2-font-mono);
+ letter-spacing: 0.02em;
+ text-transform: lowercase;
+}
+
+.v2-workspace-inspector__rows {
+ display: flex;
+ min-width: 0;
+ flex-direction: column;
+ gap: 4px;
+}
+
+.v2-root button.v2-workspace-inspector__agent,
+.v2-root button.v2-workspace-inspector__attention,
+.v2-root button.v2-workspace-inspector__task,
+.v2-root button.v2-workspace-inspector__board-counts {
+ width: 100%;
+ min-width: 0;
+ border: 0;
+ border-radius: 4px;
+ background: transparent;
+ color: var(--v2-text-primary);
+ text-align: left;
+}
+
+.v2-root button.v2-workspace-inspector__agent {
+ display: grid;
+ grid-template-columns: 28px minmax(0, 1fr) 8px;
+ align-items: center;
+ gap: 8px;
+ padding: 6px 4px;
+}
+
+.v2-root button.v2-workspace-inspector__agent:hover,
+.v2-root button.v2-workspace-inspector__attention:hover,
+.v2-root button.v2-workspace-inspector__task:hover,
+.v2-root button.v2-workspace-inspector__board-counts:hover {
+ background: var(--v2-surface-hover);
+}
+
+.v2-workspace-inspector__avatar.v2-avatar {
+ width: 28px;
+ height: 28px;
+ border: 0;
+ border-radius: 4px;
+ box-shadow: none;
+ font-size: 10px;
+}
+
+.v2-workspace-inspector__agent-copy {
+ display: flex;
+ min-width: 0;
+ flex-direction: column;
+ gap: 1px;
+}
+
+.v2-workspace-inspector__agent-name {
+ overflow: hidden;
+ color: var(--v2-text-primary);
+ font: 600 13px/18px var(--v2-font);
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.v2-workspace-inspector__agent-status {
+ overflow: hidden;
+ color: var(--v2-text-muted);
+ font: 400 12px/16px var(--v2-font);
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.v2-workspace-inspector__state {
+ width: 8px;
+ height: 8px;
+ border-radius: 0;
+ background: var(--v2-text-muted);
+}
+
+.v2-workspace-inspector__state--needs-you { background: var(--v2-accent); }
+.v2-workspace-inspector__state--working {
+ background: var(--v2-accent);
+ animation: v2-pulse var(--motion-pulse) var(--motion-ease-state) infinite;
+}
+
+.v2-root button.v2-workspace-inspector__attention,
+.v2-root button.v2-workspace-inspector__task {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: 8px;
+ padding: 7px 4px;
+ font: 400 13px/18px var(--v2-font);
+}
+
+.v2-workspace-inspector__attention > span:first-child,
+.v2-workspace-inspector__task > span:first-child {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.v2-workspace-inspector__actor,
+.v2-workspace-inspector__task-meta {
+ flex-shrink: 0;
+ color: var(--v2-accent-text);
+ font: 500 11px/14px var(--v2-font-mono);
+}
+
+.v2-workspace-inspector__task-meta { color: var(--v2-text-muted); }
+
+.v2-root button.v2-workspace-inspector__board-counts {
+ margin: 0 0 6px;
+ padding: 0 4px 7px;
+ border-bottom: 1px solid var(--v2-border-soft);
+ color: var(--v2-text-muted);
+ font: 500 11px/16px var(--v2-font-mono);
+}
+
+.v2-workspace-inspector__empty {
+ margin: 0;
+ padding: 4px;
+ color: var(--v2-text-muted);
+ font: 400 12px/16px var(--v2-font);
+}
+
+.v2-workspace-inspector__foot {
+ display: flex;
+ gap: 12px;
+ padding: 0 12px 12px;
+}
+
+.v2-root .v2-workspace-inspector__foot button {
+ min-height: auto;
+ padding: 2px 0;
+ border: 0;
+ border-radius: 0;
+ background: transparent;
+ color: var(--v2-text-secondary);
+ font: 500 11px/16px var(--v2-font-mono);
+ text-transform: lowercase;
}
+
+.v2-root button.v2-workspace-inspector__foot button:hover { color: var(--v2-text-primary); }
+
+.v2-root button.v2-workspace-inspector__close {
+ position: absolute;
+ top: 6px;
+ right: 6px;
+ z-index: 1;
+ display: none;
+ width: 28px;
+ height: 28px;
+ place-items: center;
+ padding: 0;
+ border: 0;
+ border-radius: 4px;
+ background: transparent;
+ color: var(--v2-text-secondary);
+}
+
+.v2-root button.v2-workspace-inspector__close:hover { background: var(--v2-surface-hover); color: var(--v2-text-primary); }
+
+@media (max-width: 760px) {
+ .v2-workspace-inspector { padding: 12px 14px 0; }
+ .v2-workspace-inspector__foot { padding-inline: 0; }
+ .v2-root button.v2-workspace-inspector__close { display: inline-grid; }
+}
+
+/* The new factual inspector keeps CJK metadata at the established readable
+ floor. These rows are the artboard's compact mono labels, not body prose. */
+.v2-root:lang(zh) .v2-workspace-inspector__empty { line-height: 1.6; }
+.v2-root:lang(zh) .v2-workspace-inspector__actor,
+.v2-root:lang(zh) .v2-workspace-inspector__task-meta,
+.v2-root:lang(zh) .v2-workspace-inspector__foot button { font-size: 12px; }