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 && ( <>
{item("row-menu-rename", "pencil", "Rename", () => { setEditingId(s.session_id); setEditValue(title); })} {item("row-menu-pin", "pin", s.pinned ? "Unpin" : "Pin", () => props.onTogglePin(s.session_id, !s.pinned), )} {item("row-menu-archive", "archive", s.archived ? "Unarchive" : "Archive", () => props.onArchiveSession(s.session_id, !s.archived), )}
{confirmDelId === s.session_id ? ( ) : ( )}
)} ); }; // A compact session row (mock §141 grouped/recent rows): one-line title + right-side indicators, // with the ⋮ actions kebab revealed on hover. Used in accordion bodies + grouped cards. const sessionRow = (s: SessionInfo, opts: { showTime?: boolean } = {}) => { const title = s.title || s.session_id; const editing = editingId === s.session_id; const active = s.session_id === props.activeSession; 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); }} title={editing ? undefined : title} > {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); }} /> ) : ( <> {s.pinned && } {title} {opts.showTime && compactAge(s.updated_at) && ( {compactAge(s.updated_at)} )} {rowActions(s, title)} )}
); }; // 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 (
Recent {groupMenuOpen && ( <>
setGroupMenuOpen(false)} />
Group by
{([["grouped", "Coworker"], ["flat", "Chronological"]] as ["flat" | "grouped", string][]).map( ([key, label]) => ( ), )} {filterPersonaList.length > 1 && ( <>
Filter by coworker {filterPersonas.size > 0 && ( )}
{filterPersonaList.map((p) => { const checked = filterPersonas.has(p.id); return ( ); })}
None checked shows all.
)}
)}
); }; // 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.
{shown.map((s) => sessionRow(s, { showTime: true }))} {!showAll && list.length > peek && ( )}
) : (
No conversations in this project yet.
))}
); })}
) : (
{mine.filter(matches).length === 0 ? (
{normalizedQuery ? "No matching conversations." : "No conversations yet."}
) : ( <> {(personaShowAll.has(browseKey) ? mine.filter(matches) : mine.filter(matches).slice(0, peek) ).map((s) => sessionRow(s))} {!personaShowAll.has(browseKey) && mine.filter(matches).length > peek && ( )} )}
)} {archived.length > 0 && (
{showArchived && (
{archived.filter(matches).map((s) => sessionRow(s))}
)}
)}
); }; return (
{/* 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. */}
{/* Collapse (dock) / pin the sidebar. ⌘B mirrors this. */} {props.onCollapse && ( )}
OpenWorkerBETA
{/* 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."}
) : ( <> {(recentExpanded ? recentSessions : recentSessions.slice(0, RECENT_PEEK) ).map((s) => cardRow(s))} {recentSessions.length > RECENT_PEEK && ( )} )}
)}
{/* 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
)} {appMenuItem( "inbox", "Inbox", props.onOpenInbox, props.inboxActive, , )} {appMenuItem("plug", "Connectors", props.onOpenIntegrations, props.integrationsActive)}
{appMenuItem( "gear", "Settings", props.onManage, false, ⌘ ,, )} {appMenuItem("clock", "Automations", props.onOpenScheduled, props.scheduledActive)} {appMenuItem("audit", "Activity", props.onOpenAudit, props.auditActive)} {cloud?.signed_in && ( <>
{appMenuItem("signOut", "Sign out", async () => { await cloudLogout().catch(() => {}); announceCloudChanged(); })} )}
)}
{searchModalOpen && ( { setSearchModalOpen(false); props.onSelectSession(id, ws, ag); }} onClose={() => setSearchModalOpen(false)} /> )}
); }