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')}
-
{
- setAddLinkOpen((v) => !v);
- setAddLinkError(null);
- }}
- aria-expanded={addLinkOpen}
- >
- {addLinkOpen ? t('inspector.actions.cancel') : t('inspector.artifacts.add')}
-
-
- {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}
- )}
-
- { setAddLinkOpen(false); setAddLinkUrl(''); setAddLinkError(null); }}
- disabled={addLinkBusy}
- style={{
- padding: '6px 9px',
- borderRadius: 'var(--v2-radius-sm)',
- fontSize: 11,
- fontWeight: 700,
- color: 'var(--v2-text-tertiary)',
- background: 'transparent',
- border: 'none',
- cursor: 'pointer',
- }}
- >
- {t('inspector.actions.cancel')}
-
- { void handleAddLinkSubmit(); }}
- disabled={addLinkBusy || !addLinkUrl.trim()}
- style={{
- padding: '6px 9px',
- borderRadius: 'var(--v2-radius-sm)',
- fontSize: 11,
- fontWeight: 700,
- background: addLinkBusy || !addLinkUrl.trim() ? 'var(--v2-border-strong)' : 'var(--v2-accent)',
- color: '#fff',
- border: 'none',
- cursor: addLinkBusy || !addLinkUrl.trim() ? 'not-allowed' : 'pointer',
- }}
- >
- {addLinkBusy ? t('inspector.artifacts.adding') : t('inspector.artifacts.addShort')}
-
-
-
- )}
- {artifactItems.length === 0 && !addLinkOpen ? (
- {t('inspector.artifacts.empty')}
- ) : artifactItems.length > 0 && (
-
- {artifactItems.map((a) => {
- const meta = artifactMeta(a.kind);
- return (
- onOpenArtifact(a.id)}
- >
- {meta.icon}
-
- {a.title}
- {meta.label}{a.subtitle ? ` · ${a.subtitle.replace(/^https?:\/\//, '').slice(0, 32)}` : ''}
-
-
- );
- })}
-
- )}
-
- );
-
- const membersSection = !isAgentRoom && (
-
-
- {onOpenInvite && (
-
-
-
-
- {t('inspector.members.invite')}
-
- )}
-
navigate(`/v2/agents/manage?podId=${pod._id}`)}
- title={t('inspector.members.manageTitle')}
- >
-
-
-
-
- {t('inspector.members.manage')}
-
-
- {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 (
- onOpenMember(key)}
- >
-
-
- {name}
-
- {agent.category ? `${agent.category} · ${t('inspector.aiAgent')}` : t('inspector.aiAgent')}
-
-
- {badge && (
-
- {badge.label}
- {badge.isByo && {t('inspector.runtime.byo')} }
-
- )}
- {isOnline && }
-
- );
- })}
- {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.)
- <>
- navigate(`/v2/pods/${pod._id}/board`)}
- >
- {t('inspector.runState.viewRunBoard')}
-
- {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 && (
- openPrivatePod(agent)}
- >
- {t('inspector.talkTo', { name })}
-
- )}
- {
- if (!agent.agentName) return;
- navigate(`/v2/agent/${encodeURIComponent(agent.agentName)}/${encodeURIComponent(agent.instanceId || 'default')}`);
- }}
- aria-label={t('yourTeam.card.viewProfileAria', { name })}
- >
- {t('yourTeam.card.profile')}
-
-
- {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 (
- navigate(`/v2/pods/${dm.podId}`)}
- title={t('inspector.detail.openDm', { name, peer })}
- >
- {name} ↔ {peer}
- →
-
- );
- })}
-
-
- );
- })()}
-
- );
- };
-
- // ------------------------------------------------------------------
- // 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 && (
-
- { void handleOpenArtifact(found); }}
- >
- {t('inspector.actions.open')}
-
-
- )}
- {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 (
-
-
-
- {!isOverview && (
-
-
- {t('inspector.back')}
-
- )}
- {isOverview && (
-
-
-
-
{pod.name}
-
- {t('inspector.createdByMeta', { name: createdByDisplay, date: created })}
-
-
- {!isAgentRoom && <>{t('inspector.agentCount', { count: agentCount })} · >}{t('inspector.humanCount', { count: humanCount })}
-
-
-
- )}
- {!isOverview && (
- {heading}
- )}
- {onClose && (
-
-
-
-
-
-
- )}
-
-
- {view.kind === 'overview' && (
- <>
-
- setTab('overview')}
- >
- {t('inspector.tabs.overview')}
-
- {!isAgentRoom && (
- setTab('members')}
- >
- {t('inspector.tabs.members')}
- {agentCount + humanCount}
-
- )}
- setTab('tasks')}
- >
- {t('inspector.tabs.tasks')}
- {(runState.blocked + runState.inProgress + runState.pending) > 0 && (
-
- {runState.blocked + runState.inProgress + runState.pending}
-
- )}
-
- setTab('manage')}
- >
- {t('inspector.tabs.manage')}
-
-
-
- {tab === 'overview' && (
- <>
- {pendingApprovals.length > 0 && (
-
-
- {t('inspector.approvals.title', { count: pendingApprovals.length })}
-
-
- {pendingApprovals.map((appr) => {
- const isApprovalOwner = !!currentUser?._id
- && String(currentUser._id) === String(appr.ownerUserId);
- return (
-
-
- {appr.summary}
-
-
- {appr.agentName}
- {isApprovalOwner ? (
-
- decideApproval(appr.approvalId, 'approved')}
- >
- {t('approvalCard.approve')}
-
- decideApproval(appr.approvalId, 'declined')}
- >
- {t('approvalCard.decline')}
-
-
- ) : (
-
- {t('inspector.approvals.waiting')}
-
- )}
-
-
- );
- })}
-
-
- )}
- {progressSection}
- {nowSection}
- {roomParticipantsSection}
- {pod.description && goalSection}
- {artifactsSection}
- >
- )}
- {tab === 'members' && (
- <>{membersSection ||
{t('inspector.members.notShownForDirect')}
}>
- )}
- {tab === 'tasks' && runStateSection}
- {tab === 'manage' && (
- <>
- {podsState && (
-
- {t('inspector.danger.title')}
-
- {t('inspector.danger.text')}
-
-
- {t('inspector.danger.deletePod')}
-
-
- )}
- >
- )}
- {/* Use currentUser ref so AuthContext stays imported even when not surfaced here */}
-
{currentUser?._id || ''}
- >
- )}
- {view.kind === 'member' && renderMemberDetail(view.agentKey)}
- {view.kind === 'artifact' && renderArtifactDetail(view.artifactId)}
-
-
- {/* Invite modal lives on V2Layout so the chat header invite icon
- and this section's button share one instance. */}
-
- );
-};
-
-export default V2PodInspector;
diff --git a/frontend/src/v2/components/V2PodsSidebar.tsx b/frontend/src/v2/components/V2PodsSidebar.tsx
index fcd536cd93..bc35812c37 100644
--- a/frontend/src/v2/components/V2PodsSidebar.tsx
+++ b/frontend/src/v2/components/V2PodsSidebar.tsx
@@ -1,446 +1,149 @@
-import React, { useEffect, useMemo, useRef, useState } from 'react';
+import React, { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
-import { UseV2PodsResult, V2Pod, useV2Pods } from '../hooks/useV2Pods';
-import { groupPods, formatRelativeTime } from '../utils/grouping';
-import { initialsFor } from '../utils/avatars';
-import { useV2Pinned } from '../hooks/useV2Pinned';
-import { useV2Unread } from '../hooks/useV2Unread';
+import V2Avatar from './V2Avatar';
+import { UseV2PodsResult, V2Pod, V2PodMember, useV2Pods } from '../hooks/useV2Pods';
import { useV2Api } from '../hooks/useV2Api';
-import { useSocket } from '../../context/SocketContext';
+import { V2AttentionItem } from '../hooks/useV2PodAttention';
import { useAuth } from '../../context/AuthContext';
-type Filter = 'all' | 'team' | 'private' | 'community';
-type CommunityView = 'joined' | 'discover';
-
-const FILTERS: Array<{ key: Filter; labelKey: string }> = [
- { key: 'all', labelKey: 'filters.all' },
- { key: 'team', labelKey: 'filters.team' },
- { key: 'private', labelKey: 'filters.private' },
- { key: 'community', labelKey: 'filters.community' },
-];
-const COMMUNITY_VIEWS: CommunityView[] = ['joined', 'discover'];
+interface Connector {
+ _id: string;
+ type: string;
+ status: string;
+ podId?: { _id: string; name?: string } | string | null;
+}
-// Both agent-room (user↔agent) and agent-dm (any 2-member combo) show
-// up under the DMs filter. agent-dm with two bot members gets a
-// distinct icon ("AA" — agent ↔ agent) so users can tell at a glance
-// that no human is in the conversation.
-const isDmPod = (pod: V2Pod): boolean => pod.type === 'agent-room' || pod.type === 'agent-dm';
-const podKind = (pod: V2Pod): 'team' | 'private' => (isDmPod(pod) ? 'private' : 'team');
-const isPersonalPod = (pod: V2Pod): boolean => isDmPod(pod) || pod.type === 'agent-admin';
+interface V2PodsSidebarProps {
+ selectedPodId: string | null;
+ podsState?: UseV2PodsResult;
+ attentionItems?: V2AttentionItem[];
+ // The drawer only exists below 760px. Desktop always shows this column.
+ mobileOpen?: boolean;
+ onMobileClose?: () => void;
+}
-const isAgentToAgent = (pod: V2Pod): boolean => {
- if (pod.type !== 'agent-dm') return false;
- const members = pod.members || [];
- if (members.length < 2) return false;
- return members.every((m) => typeof m === 'object' && !!m?.isBot);
+const DM_POD_TYPES = new Set(['agent-room', 'agent-dm']);
+// A new stored type must be deliberately placed in the workspace grammar.
+// Do not turn this into "not direct": that would silently surface any future
+// type as a room before product has decided how it belongs in the sidebar.
+const ROOM_POD_TYPES = new Set([
+ 'chat',
+ 'study',
+ 'games',
+ 'agent-ensemble',
+ 'agent-admin',
+ 'team',
+]);
+
+const connectorLabel = (type: string): string => ({
+ telegram: 'Telegram',
+ discord: 'Discord',
+ slack: 'Slack',
+ whatsapp: 'WhatsApp',
+ messenger: 'Messenger',
+ groupme: 'GroupMe',
+ x: 'X',
+ instagram: 'Instagram',
+}[type] || type);
+
+const podTimestamp = (pod: V2Pod): number => {
+ const value = pod.lastMessage?.createdAt || pod.updatedAt || pod.createdAt;
+ const timestamp = value ? new Date(value).getTime() : 0;
+ return Number.isNaN(timestamp) ? 0 : timestamp;
};
-const podMarkFor = (pod: V2Pod): string => {
- if (isAgentToAgent(pod)) return 'AA';
- return podKind(pod) === 'private' ? 'DM' : initialsFor(pod.name).slice(0, 2);
+const sortPods = (pods: V2Pod[]): V2Pod[] => (
+ [...pods].sort((left, right) => podTimestamp(right) - podTimestamp(left))
+);
+
+const isHumanPair = (pod: V2Pod): boolean => {
+ const members = pod.members || [];
+ return pod.type === 'chat'
+ && members.length === 2
+ && members.every((member) => typeof member === 'object' && !member.isBot);
};
-const podMarkClass = (pod: V2Pod): string => (
- `v2-pods__item-icon v2-pods__item-icon--${podKind(pod)}`
+// The stored types stay unchanged. The workspace grammar puts agent DMs and
+// human two-person chat under direct. See ROOM_POD_TYPES for the explicitly
+// reviewed room types; unknown types do not silently join a list.
+export const isDirectPod = (pod: V2Pod): boolean => (
+ DM_POD_TYPES.has(String(pod.type || '')) || isHumanPair(pod)
);
-const agentCountFor = (pod: V2Pod): number => (
- (pod.members || []).filter((member) => typeof member === 'object' && !!member?.isBot).length
+export const isRoomPod = (pod: V2Pod): boolean => (
+ ROOM_POD_TYPES.has(String(pod.type || '')) && !isHumanPair(pod)
);
-// Slack/iMessage pattern: line 2 of each row is the most recent message
-// preview, with the author prefix when it's not the current user. Falls back
-// to description/meta when the pod has no messages yet (newly-created pods).
-const podSnippetFor = (pod: V2Pod, meta: string): string => {
- const last = pod.lastMessage;
- if (last && last.content) {
- const author = last.username ? `${last.username}: ` : '';
- // Render an attached-file directive as a paperclip + filename instead of
- // leaking the raw [[upload:fileName|originalName|size|kind]] token into the
- // preview (matches how V2MessageBubble turns it into a pill in the thread).
- const content = last.content
- .replace(/\[\[upload:[^\]|]+\|([^\]|]+)\|[^\]]+\]\]/g, '📎 $1')
- .replace(/\s+/g, ' ')
- .trim();
- return `${author}${content}`;
- }
- return pod.description?.trim() || meta;
-};
-
-const matchesPersonalFilter = (pod: V2Pod, filter: Filter): boolean => {
- switch (filter) {
- // "Team" is the strictest pod-type filter — only pods explicitly typed
- // as team (multi-human collaborative). "DMs" is 1:1 conversations.
- // "All" covers everything (the redundant "Pod" filter was removed).
- case 'team': return pod.type === 'team';
- case 'private': return podKind(pod) === 'private';
- case 'community': return false;
- case 'all':
- default:
- return true;
- }
-};
-
-interface V2PodsSidebarProps {
- selectedPodId: string | null;
- podsState?: UseV2PodsResult;
- // Mobile (<=760px) slide-over drawer state. On phones the sidebar is a
- // fixed overlay rather than a grid column; `mobileOpen` drives the
- // open/closed transform and `onMobileClose` dismisses it (selecting a pod,
- // creating one). Both no-ops on desktop where the sidebar is always visible.
- mobileOpen?: boolean;
- onMobileClose?: () => void;
-}
+const directMemberFor = (pod: V2Pod, currentUserId?: string): V2PodMember | undefined => (
+ (pod.members || []).find((member): member is V2PodMember => (
+ typeof member === 'object' && member._id !== currentUserId
+ )) || (pod.members || []).find((member): member is V2PodMember => typeof member === 'object')
+);
const V2PodsSidebar: React.FC = ({
- selectedPodId, podsState, mobileOpen = false, onMobileClose,
+ selectedPodId, podsState, attentionItems = [], mobileOpen = false, onMobileClose,
}) => {
const { t } = useTranslation();
const navigate = useNavigate();
const api = useV2Api();
+ const { currentUser } = useAuth();
const ownPodsState = useV2Pods();
const {
- pods, loading, error, refresh, createPod, patchLastMessage,
+ pods, loading, error, createPod,
} = podsState || ownPodsState;
- const { pinned, toggle: togglePin, isPinned } = useV2Pinned();
- const { socket, connected, joinPod } = useSocket();
- const { currentUser } = useAuth();
- const { isUnread, bumpLatest, seedFromExisting } = useV2Unread(selectedPodId);
- const [filter, setFilter] = useState('all');
- const [search, setSearch] = useState('');
- const [communityPods, setCommunityPods] = useState([]);
- const [communityLoading, setCommunityLoading] = useState(true);
- const [communityView, setCommunityView] = useState('joined');
- const [discoverPods, setDiscoverPods] = useState([]);
- const [discoverLoading, setDiscoverLoading] = useState(false);
- const [discoverError, setDiscoverError] = useState(null);
- const [joinedDiscoverPods, setJoinedDiscoverPods] = useState([]);
- const [joiningPodId, setJoiningPodId] = useState(null);
- const [joinNotice, setJoinNotice] = useState(null);
-
- // Discovery is intentionally fetched into separate state. Merging these
- // rows into `pods` would make non-member public pods leak into All/Team,
- // undoing the membership-only sidebar invariant from #375.
- useEffect(() => {
- let active = true;
- setCommunityLoading(true);
- api.get('/api/pods?scope=community')
- .then((data) => {
- if (active) setCommunityPods(Array.isArray(data) ? data : []);
- })
- .catch(() => {
- if (active) setCommunityPods([]);
- })
- .finally(() => {
- if (active) setCommunityLoading(false);
- });
- return () => {
- active = false;
- };
- }, [api]);
-
- // Discover stays separate from the membership-backed pod list: this endpoint
- // deliberately returns pods the caller has NOT joined. Fetch it only while
- // the Discover view is active so the default personal sidebar does not pay
- // for discovery data it never renders.
- useEffect(() => {
- if (filter !== 'community' || communityView !== 'discover') return undefined;
- let active = true;
- setDiscoverLoading(true);
- setDiscoverError(null);
- api.get('/api/pods?scope=discover')
- .then((data) => {
- if (!active) return;
- setDiscoverPods((Array.isArray(data) ? data : []).filter((pod) => !isPersonalPod(pod)));
- })
- .catch(() => {
- if (!active) return;
- setDiscoverPods([]);
- setDiscoverError(t('podsSidebar.discover.loadFailed'));
- })
- .finally(() => {
- if (active) setDiscoverLoading(false);
- });
- return () => {
- active = false;
- };
- }, [api, communityView, filter, t]);
-
- // Seed lastSeen for any pod we've never observed before, using its current
- // newest-message timestamp. Without this, the very first load badges every
- // pod the user is a member of as unread — they aren't. Re-runs whenever a
- // new pod shows up; existing entries are never overwritten.
- useEffect(() => {
- if (!pods || pods.length === 0) return;
- seedFromExisting(pods.map((p) => ({
- podId: p._id,
- lastMessageAt: p.lastMessage?.createdAt || p.updatedAt || p.createdAt,
- })));
- }, [pods, seedFromExisting]);
-
- // Server-side socket auth (`authorizeSocketPodAccess`) requires the user to
- // be in `pod.members` — `/api/pods` returns discoverable pods including
- // ones the user can browse but isn't a member of, so we have to gate the
- // joinPod calls. Otherwise we eat a flood of `Not authorized to join` errors.
- const memberPodIds = useMemo(() => {
- const me = currentUser?._id;
- if (!me) return [] as string[];
- return pods
- .filter((pod) => Array.isArray(pod.members) && pod.members.some((m) => {
- if (!m) return false;
- if (typeof m === 'string') return m === me;
- return m._id === me;
- }))
- .map((pod) => pod._id);
- }, [pods, currentUser?._id]);
- const communityPodId = process.env.REACT_APP_COMMUNITY_POD_ID || '';
- const showCommunityOffer = Boolean(communityPodId) && Boolean(currentUser?._id)
- && !loading && !error
- && !memberPodIds.includes(communityPodId);
+ const [connectors, setConnectors] = useState([]);
const [creating, setCreating] = useState(false);
const [showCreate, setShowCreate] = useState(false);
const [newPodName, setNewPodName] = useState('');
const [newPodGoal, setNewPodGoal] = useState('');
- // Every pod is born private and unlisted (ADR-016). `joinPolicy: 'open'`
- // below the community tier is a DORMANT declaration — "open once listed" —
- // not an audience the creator is choosing here. Creation asks for name and
- // purpose only; visibility is set later, on a pod that has something in it.
- const newPodJoinPolicy = 'open' as const;
const [createError, setCreateError] = useState(null);
- // Pod IDs an agent is currently typing into. Set, not bool, because
- // multiple agents could type at once. Cleared after 30s safety in case the
- // stop event is missed.
- const [typingPods, setTypingPods] = useState>(() => new Set());
- const typingTimersRef = useRef>>({});
- // Join every member-pod's socket room so we hear newMessage / typing events
- // even while another pod is selected. The chat room hook (`useV2PodDetail`)
- // calls leavePod when its podId changes — without re-joining here, sidebar
- // would silently miss updates for the just-left pod. We re-fire on
- // selectedPodId change to recover those joins (joinPod is idempotent on
- // the server). No leavePod in cleanup: the server-side
- // `socket.on('disconnect')` handler clears all pod memberships when the
- // tab closes, so we don't leak rooms.
+ // Channels are the caller's existing bindings, never a discovery surface.
useEffect(() => {
- if (!socket || !connected || memberPodIds.length === 0) return;
- memberPodIds.forEach((id) => joinPod(id));
- }, [socket, connected, memberPodIds, selectedPodId, joinPod]);
-
- // Cross-pod newMessage subscription: bump lastMessage and unread badge.
- useEffect(() => {
- if (!socket || !connected) return undefined;
- interface IncomingMessage {
- pod_id?: string;
- podId?: string;
- content?: string;
- created_at?: string;
- createdAt?: string;
- user?: { username?: string; profile_picture?: string | null };
- username?: string;
- }
- const handleNewMessage = (msg: IncomingMessage) => {
- const podId = msg?.pod_id || msg?.podId;
- if (!podId) return;
- const createdAt = msg.created_at || msg.createdAt || new Date().toISOString();
- patchLastMessage(podId, {
- content: msg.content || '',
- createdAt,
- username: msg.user?.username || msg.username || null,
+ let active = true;
+ api.get('/api/integrations/user/all')
+ .then((data) => {
+ if (active) setConnectors(Array.isArray(data) ? data : []);
+ })
+ .catch(() => {
+ if (active) setConnectors([]);
});
- if (podId !== selectedPodId) bumpLatest(podId, createdAt);
- };
- socket.on('newMessage', handleNewMessage);
- return () => {
- socket.off('newMessage', handleNewMessage);
- };
- }, [socket, connected, selectedPodId, patchLastMessage, bumpLatest]);
+ return () => { active = false; };
+ }, [api]);
- // Cross-pod typing indicator. A row in the sidebar shows "typing…" if any
- // agent is mid-flight in that pod, regardless of which pod is open.
- useEffect(() => {
- if (!socket || !connected) return undefined;
- interface TypingPayload { podId?: string }
- const scheduleClear = (podId: string) => {
- if (typingTimersRef.current[podId]) clearTimeout(typingTimersRef.current[podId]);
- typingTimersRef.current[podId] = setTimeout(() => {
- setTypingPods((prev) => {
- if (!prev.has(podId)) return prev;
- const next = new Set(prev);
- next.delete(podId);
- return next;
- });
- delete typingTimersRef.current[podId];
- }, 30000);
- };
- const handleStart = (payload: TypingPayload) => {
- if (!payload?.podId) return;
- setTypingPods((prev) => {
- if (prev.has(payload.podId!)) return prev;
- const next = new Set(prev);
- next.add(payload.podId!);
- return next;
- });
- scheduleClear(payload.podId);
- };
- const handleStop = (payload: TypingPayload) => {
- if (!payload?.podId) return;
- const podId = payload.podId;
- if (typingTimersRef.current[podId]) {
- clearTimeout(typingTimersRef.current[podId]);
- delete typingTimersRef.current[podId];
- }
- setTypingPods((prev) => {
- if (!prev.has(podId)) return prev;
- const next = new Set(prev);
- next.delete(podId);
- return next;
- });
+ const { rooms, direct } = useMemo(() => {
+ const roomPods = pods.filter(isRoomPod);
+ const directPods = pods.filter(isDirectPod);
+ return { rooms: sortPods(roomPods), direct: sortPods(directPods) };
+ }, [pods]);
+
+ const attentionCountByPod = useMemo(() => attentionItems.reduce>((counts, item) => {
+ if (!item.podId) return counts;
+ counts[item.podId] = (counts[item.podId] || 0) + 1;
+ return counts;
+ }, {}), [attentionItems]);
+
+ const channels = useMemo(() => connectors.map((connector) => {
+ const podId = typeof connector.podId === 'object' ? connector.podId?._id : connector.podId;
+ const boundPod = pods.find((pod) => pod._id === podId);
+ const name = typeof connector.podId === 'object'
+ ? (connector.podId?.name || boundPod?.name || t('podsSidebar.workspace.untitled'))
+ : (boundPod?.name || t('podsSidebar.workspace.untitled'));
+ return {
+ ...connector,
+ podId: podId || null,
+ label: `${connectorLabel(connector.type)} · ${name}`,
};
- socket.on('agent_typing_start', handleStart);
- socket.on('agent_typing_stop', handleStop);
- return () => {
- socket.off('agent_typing_start', handleStart);
- socket.off('agent_typing_stop', handleStop);
- Object.values(typingTimersRef.current).forEach(clearTimeout);
- typingTimersRef.current = {};
- };
- }, [socket, connected]);
-
- const communityPodIds = useMemo(
- () => new Set(communityPods.map((pod) => pod._id)),
- [communityPods],
- );
- const communityCandidates = useMemo(() => {
- const byId = new Map();
- communityPods.forEach((pod) => byId.set(pod._id, pod));
- joinedDiscoverPods.forEach((pod) => byId.set(pod._id, pod));
- // Prefer the membership-list row when both endpoints return a pod; it is
- // the row socket/unread state already knows about.
- pods.forEach((pod) => byId.set(pod._id, pod));
- const effectiveMemberIds = new Set([
- ...memberPodIds,
- ...joinedDiscoverPods.map((pod) => pod._id),
- ]);
- return Array.from(byId.values()).filter((pod) => {
- if (isPersonalPod(pod)) return false;
- if (!effectiveMemberIds.has(pod._id)) return false;
- // Community membership is opt-in `communityListed` (the scope=community
- // result set + Discover-joined + HQ) — NOT `publicRead`. publicRead is
- // anonymous/showcase read only (#722); showcase pods like the milestone
- // and engineering rooms are publicRead but must NOT appear in Community.
- return communityPodIds.has(pod._id)
- || joinedDiscoverPods.some((joined) => joined._id === pod._id)
- || (Boolean(communityPodId) && pod._id === communityPodId);
- });
- }, [
- communityPods,
- communityPodIds,
- joinedDiscoverPods,
- pods,
- memberPodIds,
- communityPodId,
- ]);
-
- const filtered = useMemo(() => {
- const term = search.trim().toLowerCase();
- const candidates = filter === 'community'
- ? (communityView === 'discover' ? discoverPods : communityCandidates)
- : pods;
- return candidates
- .filter((pod) => filter === 'community' || matchesPersonalFilter(pod, filter))
- .filter((pod) => !term
- || (pod.name || '').toLowerCase().includes(term)
- || (pod.description || '').toLowerCase().includes(term));
- }, [communityCandidates, communityView, discoverPods, pods, filter, search]);
-
- const visibleLoading = loading || (
- filter === 'community'
- && (communityView === 'discover' ? discoverLoading : communityLoading)
- );
- const visibleError = filter === 'community' && communityView === 'discover'
- ? discoverError
- : error;
+ }), [connectors, pods, t]);
- // Default grouping is chronological (Pinned / Today / Yesterday / This week
- // / Earlier). When the user filters down to DMs, recency buckets stop
- // adding signal — DMs are already a tight list — and the meaningful split
- // is human↔agent vs agent↔agent. Bot-bot rooms are observer-only (§3.7),
- // so a section header makes the relationship explicit at a glance and
- // doubles as a count of how many a2a conversations are happening.
- const grouped = useMemo(() => {
- if (filter === 'community' && communityView === 'discover') return [];
- if (filter !== 'private') {
- // groupPods returns stable PodGroup KEYS ('Today', 'This week', …);
- // rendering them raw shipped English headers into zh-CN (Sam's
- // "cn version is messed up" report, 2026-08-23). Translate at this
- // boundary, exactly like the DM buckets below already do.
- const chronoKeys: Record = {
- Pinned: 'podsSidebar.groups.pinned',
- Today: 'podsSidebar.groups.today',
- Yesterday: 'podsSidebar.groups.yesterday',
- 'This week': 'podsSidebar.groups.thisWeek',
- Earlier: 'podsSidebar.groups.earlier',
- };
- return groupPods(filtered, pinned).map((g) => ({
- ...g,
- label: chronoKeys[g.label] ? t(chronoKeys[g.label]) : g.label,
- }));
- }
- const sortByRecency = (items: T[]): T[] => {
- const ts = (it: T) => {
- const raw = it.lastMessage?.createdAt || it.updatedAt || it.createdAt;
- const n = raw ? new Date(raw).getTime() : 0;
- return Number.isNaN(n) ? 0 : n;
- };
- return [...items].sort((a, b) => ts(b) - ts(a));
- };
- const pinnedItems = filtered.filter((p) => pinned.has(p._id));
- const rest = filtered.filter((p) => !pinned.has(p._id));
- const a2a = rest.filter((p) => isAgentToAgent(p));
- const dms = rest.filter((p) => !isAgentToAgent(p));
- const buckets: Array<{ label: string; items: typeof filtered }> = [];
- if (pinnedItems.length) buckets.push({ label: t('podsSidebar.groups.pinned'), items: sortByRecency(pinnedItems) });
- if (dms.length) buckets.push({ label: t('podsSidebar.groups.directMessages'), items: sortByRecency(dms) });
- if (a2a.length) buckets.push({ label: t('podsSidebar.groups.betweenAgents', { count: a2a.length }), items: sortByRecency(a2a) });
- return buckets;
- }, [communityView, filter, filtered, pinned, t]);
+ const nextPlatform = connectors.some((connector) => connector.type === 'slack') ? 'Telegram' : 'Slack';
- // Navigate to a pod and, on mobile, dismiss the slide-over drawer so the
- // user lands directly in the chat instead of behind the overlay.
const selectPod = (podId: string) => {
navigate(`/v2/pods/${podId}`);
onMobileClose?.();
};
- const handleJoinDiscoverPod = async (pod: V2Pod) => {
- setJoiningPodId(pod._id);
- setJoinNotice(null);
- try {
- const joined = await api.post(`/api/pods/${pod._id}/join`);
- const joinedPod = joined?._id ? joined : pod;
- setDiscoverPods((current) => current.filter((item) => item._id !== pod._id));
- setJoinedDiscoverPods((current) => [
- joinedPod,
- ...current.filter((item) => item._id !== joinedPod._id),
- ]);
- setCommunityView('joined');
- void refresh?.();
- selectPod(joinedPod._id);
- } catch (err) {
- const status = (err as { response?: { status?: number } }).response?.status;
- if (status === 403) {
- setJoinNotice(t('podsSidebar.discover.inviteRequired'));
- } else if (status === 429) {
- setJoinNotice(t('podsSidebar.discover.rateLimited'));
- } else {
- setJoinNotice(t('podsSidebar.discover.joinFailed'));
- }
- } finally {
- setJoiningPodId(null);
- }
- };
-
const handleCreatePod = async (event: React.FormEvent) => {
event.preventDefault();
const name = newPodName.trim();
@@ -448,294 +151,144 @@ const V2PodsSidebar: React.FC = ({
setCreating(true);
setCreateError(null);
try {
- const pod = await createPod(
- name,
- newPodGoal.trim() || undefined,
- 'team',
- newPodJoinPolicy,
- );
- if (pod?._id) {
- try {
- sessionStorage.setItem(`v2.justCreated.${pod._id}`, '1');
- } catch {
- // The pod still opens normally when sessionStorage is unavailable;
- // only the one-session starter panel is skipped.
- }
- setNewPodName('');
- setNewPodGoal('');
- setShowCreate(false);
- selectPod(pod._id);
- } else {
+ const pod = await createPod(name, newPodGoal.trim() || undefined, 'team', 'open');
+ if (!pod?._id) {
setCreateError(t('podsSidebar.errors.createFailed'));
+ return;
+ }
+ try {
+ sessionStorage.setItem(`v2.justCreated.${pod._id}`, '1');
+ } catch {
+ // The pod still opens normally when sessionStorage is unavailable.
}
+ setNewPodName('');
+ setNewPodGoal('');
+ setShowCreate(false);
+ selectPod(pod._id);
} finally {
setCreating(false);
}
};
+ const renderPodRow = (pod: V2Pod, kind: 'room' | 'direct') => {
+ const selected = pod._id === selectedPodId;
+ const attentionCount = attentionCountByPod[pod._id] || 0;
+ const directMember = kind === 'direct' ? directMemberFor(pod, currentUser?._id) : undefined;
+ return (
+ selectPod(pod._id)}
+ >
+ {directMember && (
+
+
+
+ )}
+ {pod.name}
+ {selected && attentionCount > 0 && (
+
+ {attentionCount}
+
+ )}
+
+ );
+ };
+
return (
-
-
- {t('podsSidebar.title')}
-
onMobileClose?.()}
- title={t('podsSidebar.closeTitle')}
- aria-label={t('podsSidebar.closeAriaLabel')}
- >
-
-
-
-
-
-
{
- setShowCreate((next) => !next);
- setCreateError(null);
- }}
- disabled={creating}
- >
-
-
-
- {t('podsSidebar.newPod')}
-
- {showCreate && (
-
- )}
-
-
-
-
-
-
-
-
setSearch(e.target.value)}
- />
-
-
-
-
- {FILTERS.map((f) => (
- {
- setFilter(f.key);
- setJoinNotice(null);
- }}
- aria-pressed={filter === f.key}
- >
- {t(`podsSidebar.${f.labelKey}`)}
-
- ))}
-
-
- {filter === 'community' && (
-
- {COMMUNITY_VIEWS.map((view) => (
+
onMobileClose?.()}
+ title={t('podsSidebar.closeTitle')}
+ aria-label={t('podsSidebar.closeAriaLabel')}
+ >
+
+
+
+
+ {loading &&
}
+ {!loading && error &&
{error}
}
+ {!loading && !error && (
+
+
+ {t('podsSidebar.workspace.rooms')}
+ {rooms.map((pod) => renderPodRow(pod, 'room'))}
{
- setCommunityView(view);
- setJoinNotice(null);
+ setShowCreate((open) => !open);
+ setCreateError(null);
}}
- aria-pressed={communityView === view}
+ disabled={creating}
>
- {t(`podsSidebar.communityViews.${view}`)}
+ + {t('podsSidebar.workspace.new')}
- ))}
-
- )}
-
-
- {visibleLoading &&
}
- {!visibleLoading && visibleError &&
{visibleError}
}
- {!visibleLoading
- && !visibleError
- && filter === 'community'
- && communityView === 'discover'
- && joinNotice && (
-
{joinNotice}
- )}
- {!visibleLoading && !visibleError && filtered.length === 0 && (
-
- {search
- ? t('podsSidebar.empty.noMatch')
- : (filter === 'community'
- ? t(
- communityView === 'discover'
- ? 'podsSidebar.discover.empty'
- : 'podsSidebar.discover.joinedEmpty',
- )
- : (filter === 'private'
- ? t('podsSidebar.empty.noDirectMessages')
- : t('podsSidebar.empty.noPods')))}
-
- )}
-
- {!visibleLoading
- && !visibleError
- && filter === 'community'
- && communityView === 'discover'
- && filtered.map((pod) => (
-
- {podMarkFor(pod)}
-
- {pod.name}
- {pod.description && (
- {pod.description}
- )}
-
- {t('podsSidebar.meta.memberCount', { count: pod.members?.length || 0 })}
-
-
- void handleJoinDiscoverPod(pod)}
- >
- {joiningPodId === pod._id
- ? t('podsSidebar.discover.joining')
- : t('podsSidebar.discover.join')}
-
-
- ))}
-
- {!visibleLoading && grouped.map((group) => (
-
-
{group.label}
- {group.items.map((pod) => {
- const memberCount = pod.members?.length || 0;
- const agentCount = agentCountFor(pod);
- const time = formatRelativeTime(pod.lastMessage?.createdAt || pod.updatedAt || pod.createdAt);
- const active = pod._id === selectedPodId;
- const meta = podKind(pod) === 'private'
- ? t('podsSidebar.meta.directMessage')
- : (agentCount > 0
- ? t('podsSidebar.meta.agentCount', { count: agentCount })
- : t('podsSidebar.meta.memberCount', { count: memberCount }));
- const snippet = podSnippetFor(pod, meta);
- const pinnedNow = isPinned(pod._id);
- const typing = typingPods.has(pod._id);
- const unread = isUnread(pod._id, pod.lastMessage?.createdAt);
- return (
-
-
selectPod(pod._id)}
- >
-
- {podMarkFor(pod)}
-
-
-
- {pod.name}
- {unread && }
-
-
-
- {typing ? t('podsSidebar.typing') : snippet}
-
- {time}
-
-
+ {showCreate && (
+
- ))}
-
+
+ )}
+
+
+
+ {t('podsSidebar.workspace.channels')}
+
+ {channels.map((connector) => (
+ connector.podId && selectPod(connector.podId)}
+ disabled={!connector.podId}
+ >
+
+ {connector.label}
+
+ ))}
+
+ navigate('/v2/connectors')}>
+ + {t('podsSidebar.workspace.connect', { platform: nextPlatform })}
+
+
- {showCommunityOffer && (
-
-
-
- {t('podsSidebar.community.copy')}
-
- {
- navigate('/v2/community');
- onMobileClose?.();
- }}
- >
- {t('podsSidebar.community.button')}
-
-
+
+ {t('podsSidebar.workspace.direct')}
+ {direct.map((pod) => renderPodRow(pod, 'direct'))}
+
+
)}
diff --git a/frontend/src/v2/components/V2PodChat.tsx b/frontend/src/v2/components/V2Thread.tsx
similarity index 53%
rename from frontend/src/v2/components/V2PodChat.tsx
rename to frontend/src/v2/components/V2Thread.tsx
index 6abff61728..cda506698b 100644
--- a/frontend/src/v2/components/V2PodChat.tsx
+++ b/frontend/src/v2/components/V2Thread.tsx
@@ -1,31 +1,38 @@
import React, {
useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState,
} from 'react';
+import MenuIcon from '@mui/icons-material/Menu';
+import ViewSidebarOutlinedIcon from '@mui/icons-material/ViewSidebarOutlined';
import V2Avatar from './V2Avatar';
import V2CatchUpStrip from './V2CatchUpStrip';
-import V2MessageBubble from './V2MessageBubble';
+import V2Composer from './V2Composer';
+import { type V2DecisionCardData, type V2DecisionRuling } from './V2DecisionCard';
+import V2ThreadMessages from './V2ThreadMessages';
+import V2ThreadStarter from './V2ThreadStarter';
import {
UseV2PodDetailResult,
- V2Agent,
} from '../hooks/useV2PodDetail';
import { useV2Api } from '../hooks/useV2Api';
-import { UseV2PodsResult, V2PodMember } from '../hooks/useV2Pods';
+import { UseV2PodsResult } from '../hooks/useV2Pods';
import { useSocket } from '../../context/SocketContext';
import { useAuth } from '../../context/AuthContext';
-import { initialsFor } from '../utils/avatars';
-import { isGroupedWithPrevious } from '../utils/messageGrouping';
-import { Trans, useTranslation } from 'react-i18next';
-import type { TFunction } from 'i18next';
+import { useTranslation } from 'react-i18next';
import type { V2InviteTab } from './V2InviteModal';
-import V2ThreadCard from './V2ThreadCard';
import { useV2ThreadState } from '../hooks/useV2ThreadState';
+import { useV2ThreadMentions } from '../hooks/useV2ThreadMentions';
import { buildThreadView } from '../utils/threadView';
import { agentKeyFor } from '../utils/agentKey';
+import {
+ buildAgentUsername,
+ isOpaqueInstanceToken,
+ normalizeAgentSegment,
+ slugifyAgentHandle,
+} from '../utils/threadAgentIdentity';
-const PLAN_MODE_KEY = 'v2.podMode';
const AGENT_DELIVERY_HINT_KEY = 'v2.agentDeliveryHint';
const JUST_CREATED_POD_KEY = 'v2.justCreated';
+const AGENT_INVITE_TAB: V2InviteTab = 'agent';
// A sent direct message is durable in the room, but it is not a reply. Give
// the person a clear, bounded wait state instead of leaving the composer to
// imply that the agent is working. Two minutes is long enough for a normal
@@ -37,79 +44,6 @@ const STARTER_PROMPT_KEYS = [
'podChat.starters.help',
'podChat.starters.firstQuestion',
] as const;
-const CLOSE_MARK = '×';
-const COMMAND_KEY = '⌘';
-const ENTER_KEY = '↵';
-
-type PodMode = 'plan' | 'execute';
-
-const podMarkFor = (name: string, type: string | undefined, dmLabel: string): string => (
- type === 'agent-room' ? dmLabel : initialsFor(name).slice(0, 2)
-);
-
-const normalizeAgentSegment = (value: string | undefined): string =>
- (value || '').toLowerCase().replace(/[^a-z0-9-]/g, '').slice(0, 40);
-
-// Per-user agents carry OPAQUE instance tokens (the u+sha10 convention, plus
-// the legacy long form) — machine keys, never identities. A moltbot's
-// instanceId ("aria") is the opposite: the human-chosen name, with agentName
-// as the runtime label we must never surface. So handle preference is:
-// human-meaningful instanceId wins; opaque token falls back to the
-// displayName slug (the backend mention map indexes displaySlug for every
-// installation), then agentName. That's how Scout gets @scout while
-// agentName stays 'guide' — and both handles land.
-const isOpaqueInstanceToken = (value: string | undefined): boolean =>
- /^u[a-f0-9]{10}([a-f0-9]{14})?$/.test((value || '').toLowerCase());
-
-// Mirrors the backend mention map's slugify (agentMentionService) — keep the
-// two identical or a typed handle can render but not resolve.
-const slugifyHandle = (value: string | undefined): string => (value || '')
- .toString()
- .trim()
- .toLowerCase()
- .replace(/[^a-z0-9-]/g, '-')
- .replace(/-+/g, '-')
- .replace(/^-+|-+$/g, '');
-
-// Mirrors backend AgentIdentityService.buildAgentUsername — instance suffix
-// elides when default/empty/equal to base name. Used to wire a mention back
-// to the agent's User row username.
-const buildAgentUsername = (agentName: string | undefined, instanceId: string | undefined): string => {
- const base = normalizeAgentSegment(agentName);
- const inst = normalizeAgentSegment(instanceId);
- if (!inst || inst === 'default' || inst === base) return base || 'agent';
- return `${base}-${inst}`;
-};
-
-// Find an "active" @-mention context in the textarea: the closest @ to the
-// left of the cursor that's at start-of-string or preceded by whitespace/
-// quotation, with no whitespace between it and the cursor. Mirrors v1
-// ChatRoom.getMentionContext so behavior matches.
-const getMentionContext = (text: string, cursor: number | null): { start: number; query: string } | null => {
- if (!text || cursor == null) return null;
- const atIndex = text.lastIndexOf('@', cursor - 1);
- if (atIndex < 0) return null;
- const beforeChar = text[atIndex - 1];
- if (beforeChar && !/\s|[([{"'`]/.test(beforeChar)) return null;
- const between = text.slice(atIndex + 1, cursor);
- if (/\s/.test(between)) return null;
- return { start: atIndex, query: between };
-};
-
-interface MentionItem {
- id: string;
- label: string;
- labelLower: string;
- subtitle: string;
- avatar?: string | null;
- isAgent: boolean;
- // Value inserted after `@`. For agents, prefer instanceId so mentions land
- // on the right instance ("@nova" not "@openclaw").
- value: string;
- // #891 surface 1: reachability shown at compose time, so nobody offers a
- // mention that can't land without saying so.
- reach?: AgentReachState;
-}
// #891 surface 1 — per-agent reachability from /api/pods/:podId/agent-states.
// Only the states the kernel can derive HONESTLY for the agent's runtime
@@ -124,7 +58,6 @@ interface AgentStateRow {
isOwner: boolean;
fixCommand?: string;
}
-const REACH_NEEDS_WARNING = new Set
(['gone-dark', 'never-connected']);
const REACH_IS_ACTIVE = new Set(['listening', 'reachable']);
interface DirectAgentIdentity {
@@ -149,6 +82,12 @@ interface TypingAgentEntry {
avatar?: string;
}
+interface ThreadDecision extends V2DecisionCardData {
+ kind: 'decision';
+ podId: string;
+ messageId: string;
+}
+
const TypingIndicator: React.FC<{ agents: TypingAgentEntry[] }> = ({ agents }) => {
const { t, i18n } = useTranslation();
if (!agents || agents.length === 0) return null;
@@ -179,30 +118,7 @@ const TypingIndicator: React.FC<{ agents: TypingAgentEntry[] }> = ({ agents }) =
);
};
-const modeCopy = (mode: PodMode, t: TFunction) => (
- mode === 'plan'
- ? t('podChat.mode.planDescription')
- : t('podChat.mode.executeDescription')
-);
-
-const readMode = (podId: string): PodMode => {
- try {
- const raw = localStorage.getItem(`${PLAN_MODE_KEY}.${podId}`);
- return raw === 'execute' ? 'execute' : 'plan';
- } catch {
- return 'plan';
- }
-};
-
-const writeMode = (podId: string, mode: PodMode) => {
- try {
- localStorage.setItem(`${PLAN_MODE_KEY}.${podId}`, mode);
- } catch {
- // localStorage unavailable; revert to default on next render.
- }
-};
-
-interface V2PodChatProps {
+interface V2ThreadProps {
detail: UseV2PodDetailResult;
podsState?: UseV2PodsResult;
// V2Layout owns the shell-level first-run modal. This flag reserves the
@@ -220,24 +136,20 @@ interface V2PodChatProps {
// path and a single modal instance handles both surfaces.
onOpenInvite?: (initialTab?: V2InviteTab) => void;
// Click on an in-message file pill routes here. Passed straight through
- // to V2MessageBubble → FilePill so the click opens the inspector
+ // to V2MessageRow → FilePill so the click opens the inspector
// artifact preview instead of window.open()'ing a raw file in a new tab.
onOpenFile?: (fileName: string) => void;
// Opens the mobile pods drawer (<=760px). The hamburger in the chat header
// is the primary way back to the pod list on phones, where the sidebar is
// an overlay rather than a visible column. Hidden via CSS on desktop.
onOpenMobileNav?: () => void;
+ // A ruling changes the one workspace attention collection owned by
+ // V2Layout, so its sidebar, inspector, and phone badge refresh together.
+ onDecisionSettled?: () => void;
}
-const Icon = ({ d }: { d: string }) => (
-
-
-
-);
-
-const V2PodChat: React.FC = ({ detail, firstRunVisible = false, inspectorCollapsed, onToggleInspector, onOpenMember, onOpenInvite, onOpenFile, onOpenMobileNav }) => {
- const { t, i18n } = useTranslation();
- const numberFormatter = new Intl.NumberFormat(i18n.resolvedLanguage || i18n.language || 'en');
+const V2Thread: React.FC = ({ detail, firstRunVisible = false, inspectorCollapsed, onToggleInspector, onOpenMember, onOpenInvite, onOpenFile, onOpenMobileNav, onDecisionSettled }) => {
+ const { t } = useTranslation();
const {
pod, members, messages, agents, sendMessage, loading, error, sendError,
hasMore, loadingOlder, loadOlder,
@@ -260,7 +172,6 @@ const V2PodChat: React.FC = ({ detail, firstRunVisible = false,
} | null>(null);
const [awaitingAgentReply, setAwaitingAgentReply] = useState(null);
const deliveryHintShownPodsRef = useRef>(new Set());
- const [mode, setMode] = useState(pod ? readMode(pod._id) : 'plan');
const messagesEndRef = useRef(null);
const messagesContainerRef = useRef(null);
const fileInputRef = useRef(null);
@@ -319,17 +230,65 @@ const V2PodChat: React.FC = ({ detail, firstRunVisible = false,
[messages, threadState.byRoot],
);
- // @-mention dropdown state. mentionStart is the index of the `@` in the
- // textarea so we know the slice to replace on select.
- const [mentionOpen, setMentionOpen] = useState(false);
- const [mentionQuery, setMentionQuery] = useState('');
- const [mentionStart, setMentionStart] = useState(-1);
- const [mentionIndex, setMentionIndex] = useState(0);
-
// #891 surface 1: agent reachability at the moment of composing a mention.
// Best-effort — a failed read renders nothing rather than something wrong,
// and 60s refresh keeps the states honest without hammering the endpoint.
const [agentStates, setAgentStates] = useState([]);
+ const [decisions, setDecisions] = useState([]);
+ const [settledDecisionByMessageId, setSettledDecisionByMessageId] = useState>(new Map());
+
+ // A DecisionRequest posts an ordinary message for its timeline position and
+ // materializes its typed choices in the attention queue. Join those two
+ // durable records by messageId; never infer a card from agent prose.
+ useEffect(() => {
+ const podId = pod?._id;
+ if (!podId) {
+ setDecisions([]);
+ return undefined;
+ }
+ let active = true;
+ const load = async () => {
+ try {
+ const data = await api.get<{ items?: ThreadDecision[] }>('/api/activity/decision-queue');
+ if (!active) return;
+ setDecisions((data?.items || []).filter((item) => (
+ item.kind === 'decision'
+ && item.podId === podId
+ && typeof item.messageId === 'string'
+ && item.messageId.length > 0
+ && Array.isArray(item.options)
+ && item.options.length > 0
+ )));
+ } catch {
+ // A queue read is additive decoration: preserve a working thread when
+ // attention is temporarily unavailable rather than inventing cards.
+ if (active) setDecisions([]);
+ }
+ };
+ void load();
+ const timer = window.setInterval(load, 15_000);
+ return () => { active = false; window.clearInterval(timer); };
+ }, [api, pod?._id]);
+
+ const decisionByMessageId = useMemo(() => new Map(
+ decisions.map((decision) => [String(decision.messageId), decision]),
+ ), [decisions]);
+
+ useEffect(() => {
+ setSettledDecisionByMessageId(new Map());
+ }, [pod?._id]);
+
+ const handleDecisionRuled = useCallback((decisionId: string, ruling: V2DecisionRuling) => {
+ const source = decisions.find((decision) => decision.id === decisionId);
+ if (!source) return;
+ setSettledDecisionByMessageId((current) => {
+ const next = new Map(current);
+ next.set(String(source.messageId), ruling);
+ return next;
+ });
+ onDecisionSettled?.();
+ }, [decisions, onDecisionSettled]);
+
useEffect(() => {
const podId = pod?._id;
if (!podId) return undefined;
@@ -367,6 +326,24 @@ const V2PodChat: React.FC = ({ detail, firstRunVisible = false,
return map;
}, [agentStates]);
+ const {
+ mentionOpen,
+ setMentionOpen,
+ mentionIndex,
+ setMentionIndex,
+ filteredMentions,
+ warnings: mentionWarnings,
+ updateMentionState,
+ selectMention,
+ } = useV2ThreadMentions({
+ members,
+ agents,
+ agentStateByHandle,
+ draft,
+ setDraft,
+ inputRef: composerInputRef,
+ });
+
// Agent rooms are exactly one human and one agent. Resolve that one agent
// through the same installation data that powers mentions, then attach the
// liveness row only when the status endpoint returned a matching row. An
@@ -433,10 +410,6 @@ const V2PodChat: React.FC = ({ detail, firstRunVisible = false,
return () => clearTimeout(timeout);
}, [awaitingAgentReply, pod?._id, messages, directAgent?.username]);
- useEffect(() => {
- if (pod) setMode(readMode(pod._id));
- }, [pod?._id]);
-
useEffect(() => {
setAgentDeliveryHint(null);
}, [pod?._id]);
@@ -556,7 +529,7 @@ const V2PodChat: React.FC = ({ detail, firstRunVisible = false,
// Map of agent-user username → per-installation displayName. Mirrors
// backend AgentIdentityService.buildAgentUsername: '' when
// instanceId is 'default' or matches the agentName, else
- // '-'. Lets V2MessageBubble render "Engineer (Nova)"
+ // '-'. Lets V2MessageRow render "Engineer (Nova)"
// instead of the raw User row username "openclaw-nova".
//
// Note: the backend payload key is `name` (per buildAgentInstallationPayload
@@ -618,155 +591,6 @@ const V2PodChat: React.FC = ({ detail, firstRunVisible = false,
[agentKeyByAuthorString],
);
- // Build the @-mention list. members[] is User rows (humans + agent users);
- // agents[] is AgentInstallation rows that carry the instanceId. We want
- // @nova in the dropdown, not @openclaw-nova — so for any member that has
- // a matching installation, promote it to the agent shape (instance handle,
- // role subtitle). Agents that aren't (yet) members are appended at the end.
- const mentionableItems: MentionItem[] = useMemo(() => {
- const items: MentionItem[] = [];
- const seen = new Set();
-
- const agentByUsername = new Map();
- (agents || []).forEach((a) => {
- const rawName = (a as { name?: string; agentName?: string }).name || a.agentName || '';
- if (!rawName) return;
- agentByUsername.set(buildAgentUsername(rawName, a.instanceId), a);
- });
-
- const itemFromAgent = (a: V2Agent, fallbackAvatar: string | null = null): MentionItem | null => {
- const rawName = (a as { name?: string; agentName?: string }).name || a.agentName || '';
- if (!rawName) return null;
- const username = buildAgentUsername(rawName, a.instanceId);
- const display = a.displayName || a.profile?.displayName || rawName;
- const instance = (a.instanceId || 'default').toLowerCase();
- // Handle preference: a human-chosen instanceId ("aria") IS the
- // identity and stays the handle — agentName there is the runtime label
- // we never surface. An OPAQUE per-user token (guide/u3f9c2a1b7d) is a
- // machine key and must never be the handle; fall back to the persona's
- // displayName slug (@scout — the mention map indexes displaySlug),
- // then the bare agentName (single-install rule).
- const mentionValue = instance && instance !== 'default' && instance !== rawName.toLowerCase()
- && !isOpaqueInstanceToken(instance)
- ? instance
- : (slugifyHandle(display) || rawName.toLowerCase());
- const avatar = a.profile?.avatarUrl || a.profile?.iconUrl || a.iconUrl || fallbackAvatar;
- // Compose-time honesty (#891 surface 1): a mention that can't land says
- // so in the picker itself — certainty-matched copy (never-connected is
- // structural and flat; gone-dark is inferred and hedged).
- const reach = agentStateByHandle.get(mentionValue)?.state
- || agentStateByHandle.get(rawName.toLowerCase())?.state;
- const subtitle = reach === 'never-connected'
- ? t('podChat.mentionState.typeaheadNever', { handle: mentionValue })
- : reach === 'gone-dark'
- ? t('podChat.mentionState.typeaheadDark', { handle: mentionValue })
- : t('podChat.mentions.agentSubtitle', { handle: mentionValue });
- return {
- id: username,
- label: display,
- labelLower: `${display} ${rawName} ${username} ${mentionValue}`.toLowerCase(),
- subtitle,
- avatar,
- isAgent: true,
- value: mentionValue,
- reach,
- };
- };
-
- (members || []).forEach((m: V2PodMember) => {
- const username = m.username || '';
- if (!username) return;
- const key = username.toLowerCase();
- if (seen.has(key)) return;
- seen.add(key);
- const agentMatch = agentByUsername.get(key);
- if (agentMatch) {
- const item = itemFromAgent(agentMatch, m.profilePicture || null);
- if (item) items.push(item);
- return;
- }
- items.push({
- id: m._id || username,
- label: username,
- labelLower: username.toLowerCase(),
- subtitle: t('podChat.mentions.member'),
- avatar: m.profilePicture || null,
- isAgent: false,
- value: username,
- });
- });
-
- (agents || []).forEach((a: V2Agent) => {
- const rawName = (a as { name?: string; agentName?: string }).name || a.agentName || '';
- if (!rawName) return;
- const username = buildAgentUsername(rawName, a.instanceId);
- const key = username.toLowerCase();
- if (seen.has(key)) return;
- seen.add(key);
- const item = itemFromAgent(a);
- if (item) items.push(item);
- });
-
- return items;
- }, [members, agents, t, agentStateByHandle]);
-
- // #891 surface 1, send-time half: which mentioned agents in the current
- // draft cannot hear it? Dependency-keyed explanation for everyone; the fix
- // command only ever arrives for owners (the endpoint enforces the split).
- const unreachableMentioned = useMemo(() => {
- if (!draft.includes('@')) return [] as AgentStateRow[];
- const tokens: string[] = draft.match(/@([a-z0-9][\w-]*)/gi) || [];
- const seen = new Set();
- const rows: AgentStateRow[] = [];
- tokens.forEach((token) => {
- const handle = token.slice(1).toLowerCase();
- const state = agentStateByHandle.get(handle);
- if (!state || !REACH_NEEDS_WARNING.has(state.state) || seen.has(handle)) return;
- seen.add(handle);
- rows.push({ ...state, agentName: handle });
- });
- return rows;
- }, [draft, agentStateByHandle]);
-
- const filteredMentions: MentionItem[] = useMemo(() => {
- if (!mentionOpen) return [];
- const q = mentionQuery.trim().toLowerCase();
- return mentionableItems.filter((item) => item.labelLower.includes(q)).slice(0, 8);
- }, [mentionOpen, mentionQuery, mentionableItems]);
-
- const updateMentionState = useCallback((nextValue: string, cursorPosition: number | null) => {
- const ctx = getMentionContext(nextValue, cursorPosition);
- if (!ctx) {
- setMentionOpen(false);
- setMentionQuery('');
- setMentionStart(-1);
- return;
- }
- setMentionOpen(true);
- setMentionQuery(ctx.query);
- setMentionStart(ctx.start);
- setMentionIndex(0);
- }, []);
-
- const handleMentionSelect = useCallback((item: MentionItem) => {
- const input = composerInputRef.current;
- if (!input) return;
- const cursor = input.selectionStart ?? draft.length;
- const start = mentionStart >= 0 ? mentionStart : draft.lastIndexOf('@', cursor);
- if (start < 0) return;
- const insert = `@${item.value || item.label}`;
- const next = `${draft.slice(0, start)}${insert} ${draft.slice(cursor)}`;
- setDraft(next);
- setMentionOpen(false);
- setMentionQuery('');
- setMentionStart(-1);
- requestAnimationFrame(() => {
- const nextCursor = start + insert.length + 1;
- input.focus();
- input.setSelectionRange(nextCursor, nextCursor);
- });
- }, [draft, mentionStart]);
-
// Clear typing indicators on pod change so we never carry indicators from
// another room into the current view.
useEffect(() => {
@@ -855,38 +679,6 @@ const V2PodChat: React.FC = ({ detail, firstRunVisible = false,
return () => document.removeEventListener('mousedown', onMouseDown);
}, [mentionOpen]);
- // Avatar row shows active members only — humans (member.isBot=false)
- // plus currently-installed agents. pod.members[] retains stale bot
- // User rows for identity continuity (ADR-001 §3), so reading it raw
- // surfaces uninstalled agents in the avatar count. Mirrors the
- // V2PodInspector filter so the chat-header "+N" agrees with the
- // Members tab count. MUST live above the `if (!pod)` early return —
- // hooks run on every render or React fires #310 on pod-state changes.
- const activeMemberAgentUsernames = React.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 effectiveMembers = React.useMemo(() => {
- // `isBot` is documented as unreliable on the wire (V2PodInspector
- // 804-807). A member whose `isBot` is falsy but whose username
- // matches an active agent would be counted twice without this
- // secondary guard.
- const humans = (members || []).filter((m) => {
- if (m.isBot) return false;
- return !activeMemberAgentUsernames.has((m.username || '').toLowerCase());
- });
- const activeAgentMembers = (members || []).filter((m) => (
- activeMemberAgentUsernames.has((m.username || '').toLowerCase())
- ));
- return [...humans, ...activeAgentMembers];
- }, [members, activeMemberAgentUsernames]);
-
// Mobile-only hamburger: opens the pods slide-over drawer. CSS hides it on
// desktop (>=761px) where the sidebar is a permanent column. Without it a
// phone user who lands in a pod chat has no way back to the pod list.
@@ -898,9 +690,7 @@ const V2PodChat: React.FC = ({ detail, firstRunVisible = false,
title={t('podChat.mobile.showPods')}
aria-label={t('podChat.mobile.showPodsList')}
>
-
-
-
+
) : null;
@@ -1009,7 +799,7 @@ const V2PodChat: React.FC = ({ detail, firstRunVisible = false,
&& exampleAgent.instanceId.toLowerCase() !== 'default'
&& !isOpaqueInstanceToken(exampleAgent.instanceId)
? exampleAgent.instanceId
- : (slugifyHandle(exampleAgent?.displayName || exampleAgent?.profile?.displayName)
+ : (slugifyAgentHandle(exampleAgent?.displayName || exampleAgent?.profile?.displayName)
|| exampleAgent?.agentName);
const mentionHandle = normalizeAgentSegment(rawMentionHandle);
if (
@@ -1126,224 +916,80 @@ const V2PodChat: React.FC = ({ detail, firstRunVisible = false,
}
};
- const handleSetMode = (next: PodMode) => {
- setMode(next);
- writeMode(pod._id, next);
- };
-
- // visibleMembers / memberCountExtra are plain consts derived from the
- // effectiveMembers useMemo that lives above the `if (!pod)` early
- // return — hooks must run on every render to satisfy the rules of
- // hooks (otherwise React #310 fires on pod-state transitions). Plain
- // consts can live here.
- const visibleMembers = effectiveMembers.slice(0, 3);
- const memberCountExtra = Math.max(0, effectiveMembers.length - visibleMembers.length);
const onlineAgentCount = agents.filter((agent) => (
!!agent.lastHeartbeatAt && Date.now() - new Date(agent.lastHeartbeatAt).getTime() < 10 * 60 * 1000
)).length;
- // A no-heartbeat state has meaning only when this pod has an installed
- // agent. Human-only pods otherwise read as broken despite having nobody
- // expected to check in.
- const liveState = agents.length > 0
- ? onlineAgentCount > 0
- ? t('podChat.heartbeat.recent', {
- count: onlineAgentCount,
- formattedCount: numberFormatter.format(onlineAgentCount),
- })
- : t('podChat.heartbeat.none')
- : null;
+ const workingClass = onlineAgentCount > 0 ? ' v2-thread__working--active' : '';
const starterPrompts = STARTER_PROMPT_KEYS.map((key) => t(key));
return (
-