import { useEffect, useMemo, useState, type ReactNode } from "react";
import {
announceCloudChanged,
AUTOMATIONS_CHANGED,
CLOUD_CHANGED,
cloudLogin,
cloudLogout,
getAutomations,
getCloudStatus,
getPersonas,
getSettings,
INBOX_UNLOCK,
PERSONAS_CHANGED,
setNavLayout,
waitForCloudSignIn,
type Automation,
type CloudStatus,
type Persona,
type RecentWorkspace,
type SurfaceVisibility,
} from "../api";
import type { SessionInfo } from "../types";
import { isProjectScoped, shortPersonaName } from "../personaScope";
import { ConnectorIcon } from "../connectors/ConnectorIcon";
import { Icon, type IconName } from "./Icon";
import { personaGlyph } from "./personaIcon";
import { SearchModal } from "./SearchModal";
import { baseName } from "../paths";
// Session surfaces shown as accordions, in display order. The surfaced personas drive this list
// (so third-party / Ops personas appear); the hardcoded set is the fallback before personas load.
const SURFACES: { key: string; label: string; icon: IconName; cls: string }[] = [
{ key: "cowork", label: "Coworker", icon: "diamond", cls: "ico-cowork" },
{ key: "chat", label: "Chat", icon: "chat", cls: "ico-chat" },
{ key: "code", label: "Code", icon: "code", cls: "ico-code" },
];
const surfaceFromPersona = (p: Persona) => ({
key: p.id,
label: shortPersonaName(p.name, p.id),
icon: personaGlyph(p.icon, p.family),
cls: `ico-${p.icon || "cowork"}`,
});
// Attention = Inbox items awaiting a session (an accent count that bubbles session → persona →
// footer Inbox — all views of the one Inbox queue, never a second list).
function AttnBadge({ n }: { n: number }) {
if (!n) return null;
return (
{n > 99 ? "99+" : n}
);
}
// UX-023: unseen-run count on a Scheduled entry. Deliberately QUIET — same neutral
// treatment as the attention badge; failure only colors the tooltip's words, not the
// sidebar (owner call 2026-07-20: no color, and the entry alone carries the count).
function UnseenBadge({ n, failed }: { n: number; failed?: boolean }) {
if (!n) return null;
return (
1 ? "s" : ""} — the latest failed` : `${n} new run${n > 1 ? "s" : ""}`}
>
{n > 99 ? "99+" : n}
);
}
// Liveness = working (in-flight turn) / sleeping (a self-wake is pending). A count-less dot that
// never bubbles — it says "this is alive", not "this needs you".
function LiveDot({ state }: { state?: "working" | "sleeping" | "idle" }) {
if (state !== "working" && state !== "sleeping") return null;
return state === "working" ? (
) : (
);
}
// §31: a session spawned by a platform mention wears its platform's logo, right-aligned beside
// the title cluster (owner call 2026-07-13). Slack today; the origin key is the platform id.
function OriginIcon({ s }: { s: SessionInfo }) {
if (s.origin !== "slack") return null;
return (
);
}
// A subscribed-connector presence dot (right edge of a row). Brand-colorless here — the sidebar
// isn't passed the connector registry — so it reads as a neutral "listening on a channel" dot.
function ConnectorDot({ subs }: { subs?: string[] }) {
if (!subs || subs.length === 0) return null;
return (
);
}
interface Props {
agent: string;
workspace: string;
surfaces: SurfaceVisibility;
sessions: SessionInfo[];
projects: RecentWorkspace[];
activeSession: string;
onSwitchAgent: (agent: string) => void;
onNewSession: (agent: string) => void;
onSelectSession: (id: string, workspace: string, agent: string) => void;
onNewProject: (persona: string) => void;
onRenameSession: (id: string, title: string) => void;
onDeleteSession: (id: string) => void;
onArchiveSession: (id: string, archived: boolean) => void;
onTogglePin: (id: string, pinned: boolean) => void;
onManage: () => void;
// Grouped-nav gear entry point (§7). "Manage coworkers…" moved to the composer's
// setup-row picker (UX-029).
onOpenPersona: (id: string) => void;
onOpenScheduled: () => void;
// Scheduled-band row click: open the Automations surface ON that automation (UX-023).
onOpenAutomation: (id: string) => void;
onOpenIntegrations: () => void;
onOpenAudit: () => void;
onOpenInbox: () => void;
scheduledActive: boolean;
integrationsActive: boolean;
auditActive: boolean;
inboxActive: boolean;
// Collapse controls (⌘B / hover-peek). `onCollapse` docks/undocks; `onPeekLeave` hides the
// floating peek when the pointer leaves the panel.
collapsed?: boolean;
onCollapse?: () => void;
onPeekLeave?: () => void;
}
// Compact age for project session rows: "now" / "5m" / "6h" / "3d" / "2w" / "4mo" / "2y".
const compactAge = (iso?: string | null): string => {
if (!iso) return "";
const then = Date.parse(iso);
if (Number.isNaN(then)) return "";
const secs = Math.max(0, Math.floor((Date.now() - then) / 1000));
if (secs < 60) return "now";
const mins = Math.floor(secs / 60);
if (mins < 60) return `${mins}m`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h`;
const days = Math.floor(hrs / 24);
if (days < 7) return `${days}d`;
const weeks = Math.floor(days / 7);
if (days < 30) return `${weeks}w`;
const months = Math.floor(days / 30);
if (days < 365) return `${months}mo`;
return `${Math.floor(days / 365)}y`;
};
// Sessions shown per group before "Show more" comes from Settings (sessions_peek, default 5).
export function Sidebar(props: Props) {
const [searchModalOpen, setSearchModalOpen] = useState(false);
const [appMenuOpen, setAppMenuOpen] = useState(false);
// The account row (§26): cloud sign-in status drives the avatar/name/dot; refreshed on
// focus and whenever the menu opens (sign-in completes out-of-band in the browser).
const [cloud, setCloud] = useState(null);
// Inbox chip sticky unlock (§26): absent until the product first parks an item (or a
// session first goes Unattended), then permanent. Per-device, like nav collapse.
const [inboxUnlocked, setInboxUnlocked] = useState(
() => localStorage.getItem("ocw:inbox-unlocked") === "1",
);
const refreshCloud = () => getCloudStatus().then(setCloud).catch(() => {});
useEffect(() => {
refreshCloud();
const onFocus = () => refreshCloud();
window.addEventListener("focus", onFocus);
window.addEventListener(CLOUD_CHANGED, onFocus);
const unlock = () => {
localStorage.setItem("ocw:inbox-unlocked", "1");
setInboxUnlocked(true);
};
window.addEventListener(INBOX_UNLOCK, unlock);
return () => {
window.removeEventListener("focus", onFocus);
window.removeEventListener(CLOUD_CHANGED, onFocus);
window.removeEventListener(INBOX_UNLOCK, unlock);
};
}, []);
// UX-023: automations feed the nav row's badge + the Scheduled band. The 15s poll
// is the baseline; mutations announce AUTOMATIONS_CHANGED for an instant refresh
// (mark-seen must clear the badge the moment the detail opens).
const [automations, setAutomations] = useState([]);
useEffect(() => {
const load = () => getAutomations().then(setAutomations).catch(() => {});
load();
const t = setInterval(load, 15_000);
window.addEventListener(AUTOMATIONS_CHANGED, load);
return () => {
clearInterval(t);
window.removeEventListener(AUTOMATIONS_CHANGED, load);
};
}, []);
const [editingId, setEditingId] = useState(null);
const [editValue, setEditValue] = useState("");
// Two-step delete inside the row's ⋮ menu: Delete arms ("Delete?"), a second click deletes.
// Archive is the primary way to put a conversation away — one click, reversible.
const [confirmDelId, setConfirmDelId] = useState(null);
// The open row-actions ⋮ menu (one at a time). Fixed-position, not absolute: the expanded
// accordion group clips overflow (its rounded fill), so an absolute popover on its lower rows
// would be cut off — same constraint as SlackDetail's person picker.
const [rowMenu, setRowMenu] = useState<{
id: string;
top: number;
left: number;
anchor: HTMLElement;
} | null>(null);
const closeRowMenu = () => {
setRowMenu(null);
setConfirmDelId(null);
};
const openRowMenu = (id: string, anchor: HTMLElement) => {
const r = anchor.getBoundingClientRect();
const MENU_W = 160; // w-40
const MENU_H = 150; // ~4 items + divider; only used to flip upward near the window bottom
setConfirmDelId(null);
setRowMenu({
id,
top: r.bottom + 4 + MENU_H > window.innerHeight ? r.top - MENU_H : r.bottom + 4,
left: Math.max(8, r.right - MENU_W),
anchor,
});
};
useEffect(() => {
if (!rowMenu) return;
const onKey = (e: KeyboardEvent) => e.key === "Escape" && closeRowMenu();
// Scrolling an ANCESTOR of the anchor row detaches the fixed menu from it — dismiss.
// Filter by containment: unrelated scrollers (the transcript auto-follow during a
// streaming turn fires constantly) must not close the menu.
const onScroll = (e: Event) => {
const t = e.target;
if (t === document || (t instanceof Node && t.contains(rowMenu.anchor))) closeRowMenu();
};
window.addEventListener("keydown", onKey);
window.addEventListener("scroll", onScroll, true);
return () => {
window.removeEventListener("keydown", onKey);
window.removeEventListener("scroll", onScroll, true);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [rowMenu]);
const [showArchived, setShowArchived] = useState(false);
// Surfaced + enabled personas drive the surface list + family-aware behavior.
// Refetched on the personas-changed event so an enable/install/delete in Settings
// shows up here immediately (no page refresh).
const [personas, setPersonas] = useState(null);
useEffect(() => {
const load = () =>
getPersonas()
.then(setPersonas)
.catch(() => setPersonas(null));
load();
window.addEventListener(PERSONAS_CHANGED, load);
return () => window.removeEventListener(PERSONAS_CHANGED, load);
}, []);
const personaOf = (id: string) => personas?.find((p) => p.id === id);
// Sidebar layout (§7): "grouped" = the per-coworker accordion; "flat" = a single
// ungrouped list (Pinned + Recent). Flat stays the default even with Coworkers shipped
// (UX-029 flips the flag for the picker, not the nav shape — the flat chronological
// list default is the 2026-07-20 owner call). An explicit stored choice always wins.
const defaultLayout: "flat" | "grouped" = "flat";
const [layout, setLayout] = useState<"flat" | "grouped">(defaultLayout);
// Sessions shown per group before "Show more" — Settings ▸ Appearance ▸ Sidebar.
const [peek, setPeek] = useState(5);
useEffect(() => {
getSettings()
.then((s) => {
setLayout(
s.nav_layout === "flat" ? "flat" : s.nav_layout === "grouped" ? "grouped" : defaultLayout,
);
if (s.sessions_peek) setPeek(s.sessions_peek);
})
.catch(() => {});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const setGroupBy = (next: "flat" | "grouped") => {
setLayout(next);
setNavLayout(next).catch(() => {});
};
// Chronological RECENT list: cap at RECENT_PEEK with a Show more/less toggle so the sidebar
// doesn't grow unbounded.
const RECENT_PEEK = 4;
const [recentExpanded, setRecentExpanded] = useState(false);
// The RECENT-header group/filter popover (§20). Filter = show only these personas (empty = all).
const [groupMenuOpen, setGroupMenuOpen] = useState(false);
const [filterPersonas, setFilterPersonas] = useState>(new Set());
const toggleFilterPersona = (id: string) =>
setFilterPersonas((prev) => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
const personaVisible = (agent: string) =>
filterPersonas.size === 0 || filterPersonas.has(agent);
// Which accordion body is expanded. Decoupled from the active session (props.agent): expanding
// a persona BROWSES its sessions without switching the chat area. Selecting a session or "New
// session" is what switches (and re-opens that persona). Falls back to the active persona.
const [openKey, setOpenKey] = useState(props.agent);
useEffect(() => setOpenKey(props.agent), [props.agent]);
const browseKey = openKey ?? props.agent; // the persona whose sessions the body shows
// Per-project collapse + "Show more". The active workspace's folder is open by default; toggling
// any folder flips it (XOR). `projShowAll` lifts the peek cap for a given folder;
// `personaShowAll` does the same for a (non-project) persona's flat session list.
const [projToggled, setProjToggled] = useState>(new Set());
const [projShowAll, setProjShowAll] = useState>(new Set());
const [personaShowAll, setPersonaShowAll] = useState>(new Set());
const toggleSet = (set: Set, key: string) => {
const next = new Set(set);
next.has(key) ? next.delete(key) : next.add(key);
return next;
};
// Pinned sessions across ALL personas — the cross-persona band at the top (manual pins only).
const pinnedSessions = props.sessions.filter(
(s) => s.pinned && !s.session_id.startsWith("__") && !s.archived,
);
// §31 (revised 2026-07-21): mention-spawned sessions list chronologically in Recent like any
// other session — the OriginIcon in the row's indicator cluster marks where they came from.
// The separate collapsed "From Slack" band hid fresh mentions below week-old sessions.
// A row in the account menu (§26): closes the menu, then runs the destination.
const appMenuItem = (
icon: IconName,
label: string,
onClick: () => void,
active?: boolean,
trailing?: ReactNode,
) => (
);
// Display identity for the account row: the cloud profile only carries the email, so the
// row shows the capitalized local part ("rohit@…" → "Rohit"); the menu header shows it all.
const accountEmail = cloud?.signed_in ? cloud.account : "";
const accountName = accountEmail
? accountEmail.split("@")[0].replace(/^./, (c) => c.toUpperCase())
: "";
// Roll the per-session attention/liveness up to the persona header and the footer Inbox: the
// accent count bubbles (sum), the liveness dot aggregates (working wins over sleeping).
const attnByPersona = new Map();
const liveByPersona = new Map();
let totalAttention = 0;
for (const s of props.sessions) {
if (s.session_id.startsWith("__") || s.archived) continue;
const a = s.attention || 0;
if (a > 0) {
attnByPersona.set(s.agent, (attnByPersona.get(s.agent) || 0) + a);
totalAttention += a;
}
if (s.liveness === "working") liveByPersona.set(s.agent, "working");
else if (s.liveness === "sleeping" && liveByPersona.get(s.agent) !== "working")
liveByPersona.set(s.agent, "sleeping");
}
// First pending item ever observed → the inbox chip unlocks and stays (§26 sticky unlock).
useEffect(() => {
if (totalAttention > 0 && !inboxUnlocked) {
localStorage.setItem("ocw:inbox-unlocked", "1");
setInboxUnlocked(true);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [totalAttention]);
// Body data is keyed to the BROWSED persona (only one body renders at a time). Pinned sessions are
// EXCLUDED here: they live in the cross-persona Pinned band only, so they don't repeat inside the
// persona group / project list (matching the flat layout's Recent, which also drops pinned).
const all = props.sessions.filter(
(s) =>
s.agent === browseKey &&
!s.session_id.startsWith("__") &&
s.team?.role !== "worker", // workers nest under their lead, never top-level
);
const mine = all.filter((s) => !s.archived && !s.pinned);
const archived = all.filter((s) => s.archived);
// Only PROJECT-SCOPED personas group sessions by project (git-bound Code, project-bound Ops).
// Scratch/deliverable conversations are orphan (each has its own per-conversation scratch dir),
// so they list flat. Workspace-aware (not id-aware) — any git/project persona gets Projects.
const workspaceSurface = isProjectScoped(personaOf(browseKey));
// Search now lives in the SearchModal (command-palette overlay), so the sidebar lists never filter
// in place — these stay constant and the `.filter(matches)` / `normalizedQuery ? …` call sites
// below are intentional no-ops kept to avoid churn.
const normalizedQuery = "";
const matches = (_s: SessionInfo) => true;
// Recent = every non-pinned, non-archived, real session across ALL personas, newest first
// (by updated_at; missing timestamps keep store order), search-filtered. Drives the flat layout.
// Team workers never appear top-level: they nest under their lead's ONE expandable entry.
const recentSessions = [...props.sessions]
.filter((s) => !s.archived && !s.session_id.startsWith("__") && !s.pinned)
.filter((s) => s.team?.role !== "worker")
.filter((s) => personaVisible(s.agent))
.filter(matches)
.sort((a, b) => (b.updated_at || "").localeCompare(a.updated_at || ""));
// Row actions live behind ONE ⋮ kebab per row (FB-011: four hover icons read as clutter) —
// the menu offers Rename · Pin/Unpin · Archive/Unarchive · Delete, with the two-step delete
// confirm kept inside it. Shared by BOTH row styles, so the chronological cardRow offers the
// same actions as the persona accordion's sessionRow (owner ask 2026-07-09).
const rowActions = (s: SessionInfo, title: string) => {
const menuOpen = rowMenu?.id === s.session_id;
const item = (testid: string, icon: IconName, label: string, onClick: () => void) => (
);
return (
e.stopPropagation()}
>
{menuOpen && (
<>
);
};
// A single-line card row (mock §141 list-flat, subtitle dropped 2026-07-21): title +
// right-side indicators, with the ⋮ actions kebab revealed on hover. Shared by the flat
// layout's Pinned and Recent sections. Personas are disabled for the first release; when
// they return, surface the persona on hover (e.g. in the row tooltip) — not as a subtitle.
const cardRow = (s: SessionInfo) => {
const active = s.session_id === props.activeSession;
const title = s.title || s.session_id;
const editing = editingId === s.session_id;
const commitRename = () => {
const next = editValue.trim();
if (next && next !== title) props.onRenameSession(s.session_id, next);
setEditingId(null);
};
return (
{
if (!editing) props.onSelectSession(s.session_id, s.workspace, s.agent);
}}
>
{/* No leading glyph on session rows (Rohit's call 2026-07-07: the per-session icon
read as noise in both grouped and chronological). Team leads are plain rows too —
worker rows live in the drawer's Team panel (seventeenth pass). */}
{editing ? (
e.stopPropagation()}
onChange={(e) => setEditValue(e.target.value)}
onBlur={commitRename}
onKeyDown={(e) => {
e.stopPropagation();
if (e.key === "Enter") commitRename();
else if (e.key === "Escape") setEditingId(null);
}}
/>
) : (
<>
{title}
{rowActions(s, title)}
>
)}
);
};
// The cross-persona Pinned band (manual pins only) — icon-free rows. Appears in BOTH layouts
// (flat list AND accordion), so it's factored here for reuse.
const pinnedBand = () =>
pinnedSessions.length > 0 ? (
Pinned
{pinnedSessions.map((s) => cardRow(s))}
) : null;
// UX-023: the Scheduled band — ONE entry per automation (never per run): name +
// cadence, with the unseen-runs badge. Runs themselves never enter Recent (run
// sessions are __run__-prefixed and hidden from the sessions list).
const scheduledBand = () =>
automations.length > 0 ? (
Scheduled
{automations.map((a) => (
))}
) : null;
// RECENT header with the group/filter control (§20) — the group toggle moved off the brand bar.
// "Group by" flips the persona accordion ↔ chronological list; "Filter by coworker" narrows to
// the checked personas (none checked = all shown).
const recentHeader = () => {
const filterPersonaList = (personas || []).filter(
(p) => (p.enabled && p.surfaced) || agentsWithSessions.has(p.id),
);
return (
);
};
// Code/Cowork group by project; Chat is a flat recents list.
const byProject = useMemo(() => {
const grouped = new Map();
for (const s of mine) {
if (!grouped.has(s.workspace)) grouped.set(s.workspace, []);
grouped.get(s.workspace)!.push(s);
}
return grouped;
}, [mine]);
const filteredByProject = useMemo(() => {
const grouped = new Map();
for (const [proj, list] of byProject) grouped.set(proj, list.filter(matches));
return grouped;
}, [byProject, normalizedQuery]);
// Projects are tracked PER SURFACE: a folder appears under Code only if it has Code sessions,
// under Cowork only if it has Cowork sessions (+ the currently-open folder). No cross-bleed.
const projectOrder: string[] = [];
const seen = new Set();
// Pin the active folder at top only when browsing the active persona (else it belongs elsewhere).
if (props.workspace && browseKey === props.agent) {
projectOrder.push(props.workspace);
seen.add(props.workspace);
}
for (const s of mine) {
if (s.workspace && !seen.has(s.workspace)) {
seen.add(s.workspace);
projectOrder.push(s.workspace);
}
}
// Surfaced + enabled personas drive the surface list (default persona first); fall back to the
// static set until loaded. A persona that has live sessions ALWAYS gets a section, surfaced or
// not — every session must have a home in the grouped layout (a picker preference can hide the
// persona from New Session, never orphan its conversations).
const agentsWithSessions = new Set(
props.sessions
.filter(
(s) =>
!s.archived &&
!s.session_id.startsWith("__") &&
s.team?.role !== "worker",
)
.map((s) => s.agent),
);
const visibleSurfaces = (
personas
? personas
.filter((p) => (p.enabled && p.surfaced) || agentsWithSessions.has(p.id))
.sort((a, b) => Number(b.default) - Number(a.default)) // default leads
.map(surfaceFromPersona)
: SURFACES.filter(
(s) => s.key === "cowork" || props.surfaces[s.key as keyof SurfaceVisibility],
)
).filter((s) => personaVisible(s.key));
const isCurrent = (key: string) => props.agent === key; // the active session's persona
const isExpanded = (key: string) => openKey === key; // its body is open
// Expand ≠ switch: clicking a header only browses (toggles the accordion). The chat area
// changes only when a session is selected or "New session" is clicked.
const onHeaderClick = (key: string) => setOpenKey((k) => (k === key ? null : key));
// The expanded body for the active surface: a "New session" action, then the project-grouped
// (or flat) session list, then the archived disclosure.
const surfaceBody = () => {
return (
{/* Body is flush inside the expanded group's fill (provided by the wrapper) so the header +
its sessions read as one connected block — clear where a group ends and the next begins. */}
{/* No per-persona "New session" here — the top split button's ▾ already starts a session
in any persona (it was redundant + the mock's grouped cards don't have it). */}
{workspaceSurface ? (
<>
{/* Codex-style Projects: a "+" header affordance, then collapsible folders whose
rows carry a right-aligned compact age and truncate to PROJECT_PEEK + "Show more". */}
Projects
{projectOrder.length === 0 && (
No projects yet — start one with the + above.
)}
{projectOrder.map((proj) => {
const list = filteredByProject.get(proj) || [];
if (normalizedQuery && list.length === 0) return null; // hide non-matching folders while searching
const isActive = proj === props.workspace;
// Open the active project by default; if none is active (browsing from another
// persona), open the most-recent folder so the accordion isn't all-collapsed.
const activeInOrder = !!props.workspace && projectOrder.includes(props.workspace);
const defaultOpen = isActive || (!activeInOrder && proj === projectOrder[0]);
const open = !!normalizedQuery || defaultOpen !== projToggled.has(proj);
const showAll = !!normalizedQuery || projShowAll.has(proj);
const shown = showAll ? list : list.slice(0, peek);
return (
setProjToggled((s) => toggleSet(s, proj))}
title={proj}
>
{baseName(proj)}
{/* Disclosure chevron sits AFTER the name (Codex parity), not leading the row. */}
{open &&
(list.length > 0 ? (
// pl-[19px] aligns each session's name under the folder NAME (folder icon
// 15 + gap 6 + row px 6 − session px 8 = 19), per Rohit's clean-column ask.
{/* Header: collapse/pin control FIRST + wordmark. The pin sits at the same screen position
as the collapsed reveal button (see .nav-pin-btn / .nav-reveal-btn in styles.css), so
hovering the reveal peeks the nav and the pin lands right under the cursor — no travel.
data-tauri-drag-region drags the window; on desktop the row clears the traffic lights. */}
{/* New session: a plain button — the coworker pick moved to the composer's setup
row (UX-029), so the old ▾ persona menu is gone. Starts the last-used persona;
the setup row re-targets the draft in place. */}
{/* Search: a borderless nav-style entry (not a boxed input) that opens the command-palette
SearchModal over the whole app. Matches the bottom-nav rows to reduce the boxy look. */}
{/* Automations: a first-class nav row (UX-023) — the account menu keeps its entry.
The badge is the cross-automation unseen-run total. */}
{/* Scroll area: Pinned band + the RECENT header (with group/filter control), then the body —
grouped (per-persona accordion) or flat (chronological list). */}
{pinnedBand()}
{scheduledBand()}
{recentHeader()}
{layout === "grouped" ? (
{visibleSurfaces.map((s) => {
const expanded = isExpanded(s.key);
return (
// When expanded, the wrapper carries the recessed fill so the header sits INSIDE
// the block with its sessions (one connected group). Collapsed = a plain row.
onHeaderClick(s.key)}
>
{s.label}
{/* Persona configuration moved to Settings ▸ Personas (Rohit's call
2026-07-07) — the per-group gear read as clutter here. */}
{expanded && surfaceBody()}
);
})}
) : (
{recentSessions.length === 0 ? (
{normalizedQuery ? "No matching conversations." : "No conversations yet."}
{/* Bottom (§26): exactly ONE row — the account anchor. The inbox chip on it is
state-driven with a sticky unlock (quiet when empty, accent + count when pending);
everything else lives in the account menu, which ALWAYS lists Inbox + Connectors. */}
{appMenuOpen && (
<>
setAppMenuOpen(false)} />
{cloud?.signed_in ? (
{accountEmail} · OpenWorker Cloud
) : (
<>
Not signed in — one-click connections need OpenWorker Cloud