Files
openworker/surfaces/gui/src/components/Sidebar.tsx
T
Rohit C Prasad ce3353dab3 GUI: right-drawer restructure — Team panel, three primaries + More, board chip
Team members move from sidebar to a drawer panel; RECENT keeps one entry per team.
All sections collapsed by default; Journal/Access fold behind More; leads drop Progress.
Lead mentions the board once via a [.](board:) chip that opens the drawer.
2026-08-17 18:44:39 -07:00

1302 lines
56 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 (
<span
className="text-[10px] font-semibold text-ink bg-faint/30 rounded-full px-1.5 leading-[15px] shrink-0"
title={`${n} awaiting your attention`}
>
{n > 99 ? "99+" : n}
</span>
);
}
// 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 (
<span
className="text-[10px] font-semibold text-ink bg-faint/30 rounded-full px-1.5 leading-[15px] shrink-0"
title={failed ? `${n} new run${n > 1 ? "s" : ""} — the latest failed` : `${n} new run${n > 1 ? "s" : ""}`}
>
{n > 99 ? "99+" : n}
</span>
);
}
// 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" ? (
<span className="w-1.5 h-1.5 rounded-full bg-accent animate-pulse shrink-0" title="Working now" />
) : (
<span
className="w-1.5 h-1.5 rounded-full bg-faint/60 shrink-0"
title="Sleeping (will wake itself)"
/>
);
}
// §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 (
<ConnectorIcon
connector={{ logo: "slack", brand_color: "#611f69" }}
size={12}
title={s.origin_label || "From Slack"}
/>
);
}
// 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 (
<span
className="w-1.5 h-1.5 rounded-full bg-faint shrink-0"
data-brand={subs[0]}
title={subs.join(", ")}
/>
);
}
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<CloudStatus | null>(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<Automation[]>([]);
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<string | null>(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<string | null>(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<Persona[] | null>(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<Set<string>>(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<string | null>(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<Set<string>>(new Set());
const [projShowAll, setProjShowAll] = useState<Set<string>>(new Set());
const [personaShowAll, setPersonaShowAll] = useState<Set<string>>(new Set());
const toggleSet = (set: Set<string>, 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,
) => (
<button
className={
"w-full flex items-center gap-2.5 px-3 py-1.5 text-[13px] text-left " +
(active ? "text-ink bg-paper" : "hover:bg-paper")
}
onClick={() => {
setAppMenuOpen(false);
onClick();
}}
>
<Icon name={icon} size={15} className="shrink-0 text-muted" />
<span className="flex-1">{label}</span>
{/* aria-hidden: the badge/shortcut must not leak into the accessible name (the old
Inbox row's name-includes-the-badge-count nuisance, not repeated). */}
{trailing != null && <span aria-hidden>{trailing}</span>}
</button>
);
// 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<string, number>();
const liveByPersona = new Map<string, "working" | "sleeping">();
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) => (
<button
className="w-full flex items-center gap-2 px-2.5 py-1.5 text-[12.5px] text-left hover:bg-paper"
data-testid={testid}
role="menuitem"
onClick={() => {
closeRowMenu();
onClick();
}}
>
<Icon name={icon} size={13} className="shrink-0 text-muted" />
<span className="flex-1">{label}</span>
</button>
);
return (
<span
// Stay visible while this row's menu is open — the pointer may be on the menu, off the row.
className={(menuOpen ? "flex" : "hidden group-hover:flex") + " items-center shrink-0"}
onClick={(e) => e.stopPropagation()}
>
<button
title="Session actions"
aria-label="Session actions"
aria-haspopup="menu"
aria-expanded={menuOpen}
data-testid="row-menu"
className={
"w-5 h-5 grid place-items-center rounded hover:bg-paper " +
(menuOpen ? "text-ink bg-paper" : "text-faint hover:text-ink")
}
onClick={(e) => (menuOpen ? closeRowMenu() : openRowMenu(s.session_id, e.currentTarget))}
>
{/* Vertical kebab = the horizontal glyph rotated — no extra icon needed. */}
<Icon name="moreHorizontal" size={14} className="rotate-90" />
</button>
{menuOpen && (
<>
<div className="fixed inset-0 z-40" onClick={closeRowMenu} />
<div
className="fixed z-50 w-40 rounded-xl border border-line bg-panel shadow-xl py-1"
style={{ top: rowMenu!.top, left: rowMenu!.left }}
role="menu"
>
{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),
)}
<div className="h-px bg-line my-1 mx-2" />
{confirmDelId === s.session_id ? (
<button
title="Click again to permanently delete"
className="w-full flex items-center gap-2 px-2.5 py-1.5 text-[12.5px] text-left font-medium text-danger hover:bg-paper"
data-testid="row-menu-delete"
role="menuitem"
onClick={() => {
closeRowMenu();
props.onDeleteSession(s.session_id);
}}
>
<Icon name="trash" size={13} className="shrink-0" />
<span className="flex-1">Delete?</span>
</button>
) : (
<button
className="w-full flex items-center gap-2 px-2.5 py-1.5 text-[12.5px] text-left text-danger hover:bg-paper"
data-testid="row-menu-delete"
role="menuitem"
onClick={() => setConfirmDelId(s.session_id)}
>
<Icon name="trash" size={13} className="shrink-0" />
<span className="flex-1">Delete</span>
</button>
)}
</div>
</>
)}
</span>
);
};
// 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 (
<div
key={s.session_id}
className={
"group flex items-center gap-2 px-2 py-1.5 rounded-lg text-left cursor-pointer " +
(active
? "bg-ink/[0.055]"
: "hover:bg-panel")
}
onClick={() => {
if (!editing) props.onSelectSession(s.session_id, s.workspace, s.agent);
}}
title={editing ? undefined : title}
>
{editing ? (
<input
className="flex-1 min-w-0 px-1.5 py-0.5 rounded-md bg-panel border border-accent text-[13px] text-ink outline-none"
value={editValue}
autoFocus
onClick={(e) => 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);
}}
/>
) : (
<>
<span
className={
"min-w-0 flex-1 flex items-center gap-1.5 truncate text-[13px] " +
(active ? "font-medium text-ink" : "text-ink")
}
>
{s.pinned && <Icon name="pin" size={11} className="text-faint shrink-0" />}
<span className="truncate">{title}</span>
</span>
<span
className={
"flex items-center gap-1.5 shrink-0 group-hover:hidden" +
(rowMenu?.id === s.session_id ? " hidden" : "")
}
>
{opts.showTime && compactAge(s.updated_at) && (
<span className="text-[11px] text-faint tabular-nums">{compactAge(s.updated_at)}</span>
)}
<OriginIcon s={s} />
<LiveDot state={s.liveness} />
<AttnBadge n={s.attention || 0} />
</span>
{rowActions(s, title)}
</>
)}
</div>
);
};
// 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 (
<div
key={s.session_id}
className={
"group w-full flex items-center gap-2.5 px-2 py-2 rounded-lg cursor-pointer text-left " +
(active
? "bg-ink/[0.055]"
: "hover:bg-paper")
}
title={editing ? undefined : title}
onClick={() => {
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 ? (
<input
className="flex-1 min-w-0 px-1.5 py-0.5 rounded-md bg-panel border border-accent text-[13px] text-ink outline-none"
value={editValue}
autoFocus
onClick={(e) => 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);
}}
/>
) : (
<>
<span className="min-w-0 flex-1 block truncate text-[13px] font-medium">
{title}
</span>
<span
className={
"flex items-center gap-1.5 shrink-0 group-hover:hidden" +
(rowMenu?.id === s.session_id ? " hidden" : "")
}
>
<OriginIcon s={s} />
<ConnectorDot subs={s.subscriptions} />
<LiveDot state={s.liveness} />
<AttnBadge n={s.attention || 0} />
</span>
{rowActions(s, title)}
</>
)}
</div>
);
};
// 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 ? (
<div>
<div className="px-1.5 text-[10.5px] uppercase tracking-[0.07em] text-faint font-semibold mb-1">
Pinned
</div>
<div className="space-y-0.5">
{pinnedSessions.map((s) => cardRow(s))}
</div>
</div>
) : 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 ? (
<div data-testid="scheduled-band">
<div className="px-1.5 text-[10.5px] uppercase tracking-[0.07em] text-faint font-semibold mb-1">
Scheduled
</div>
<div className="space-y-0.5">
{automations.map((a) => (
<button
key={a.id}
className="w-full flex items-center gap-2 px-1.5 py-1 rounded-lg text-left hover:bg-paper"
data-testid={`scheduled-${a.id}`}
title={a.title}
onClick={() => props.onOpenAutomation(a.id)}
>
<div className="flex-1 min-w-0">
<div className="text-[13px] text-ink truncate">{a.title}</div>
<div className="text-[11px] text-faint truncate">{a.schedule}</div>
</div>
<UnseenBadge n={a.unseen_runs || 0} failed={a.unseen_failed} />
</button>
))}
</div>
</div>
) : 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 (
<div className="relative flex items-center justify-between px-1.5 mb-1" data-testid="recent-header">
<span className="text-[10.5px] uppercase tracking-[0.07em] text-faint font-semibold">
Recent
</span>
<button
className="w-6 h-6 grid place-items-center rounded-md text-faint hover:text-ink hover:bg-paper -mr-1"
title="Group & filter conversations"
aria-label="Group and filter conversations"
onClick={() => setGroupMenuOpen((v) => !v)}
>
<Icon name="sliders" size={14} />
</button>
{groupMenuOpen && (
<>
<div className="fixed inset-0 z-40" onClick={() => setGroupMenuOpen(false)} />
<div
className="absolute right-0 top-7 z-50 w-56 rounded-xl border border-line bg-panel shadow-xl p-1.5"
role="menu"
data-testid="group-filter-menu"
>
<div className="px-2 pt-1 pb-1 text-[10.5px] uppercase tracking-[0.06em] text-faint font-semibold">
Group by
</div>
{([["grouped", "Coworker"], ["flat", "Chronological"]] as ["flat" | "grouped", string][]).map(
([key, label]) => (
<button
key={key}
className="w-full flex items-center gap-2 px-2 py-1.5 rounded-lg text-[13px] text-left hover:bg-paper"
onClick={() => setGroupBy(key)}
>
<span className="flex-1">{label}</span>
{layout === key && <span className="text-accent text-[12px]"></span>}
</button>
),
)}
{filterPersonaList.length > 1 && (
<>
<div className="my-1 border-t border-line" />
<div className="px-2 pt-1 pb-1 flex items-center justify-between">
<span className="text-[10.5px] uppercase tracking-[0.06em] text-faint font-semibold">
Filter by coworker
</span>
{filterPersonas.size > 0 && (
<button className="text-[11px] text-accent" onClick={() => setFilterPersonas(new Set())}>
Clear
</button>
)}
</div>
<div className="max-h-52 overflow-y-auto">
{filterPersonaList.map((p) => {
const checked = filterPersonas.has(p.id);
return (
<button
key={p.id}
className="w-full flex items-center gap-2 px-2 py-1.5 rounded-lg text-[13px] text-left hover:bg-paper"
onClick={() => toggleFilterPersona(p.id)}
>
<span
className={
"w-3.5 h-3.5 rounded border grid place-items-center shrink-0 text-white " +
(checked ? "bg-accent border-accent" : "border-line")
}
>
{checked && <span className="text-[9px] leading-none"></span>}
</span>
<span className="flex-1 truncate">{p.name}</span>
</button>
);
})}
</div>
<div className="px-2 pt-1 pb-0.5 text-[11px] text-faint leading-snug">
None checked shows all.
</div>
</>
)}
</div>
</>
)}
</div>
);
};
// Code/Cowork group by project; Chat is a flat recents list.
const byProject = useMemo(() => {
const grouped = new Map<string, SessionInfo[]>();
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<string, SessionInfo[]>();
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<string>();
// 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 (
<div className="space-y-1 px-1.5 pb-2 pt-0.5">
{/* 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". */}
<div className="flex items-center justify-between px-1.5 pt-1">
<span className="text-[10.5px] uppercase tracking-[0.07em] text-faint font-semibold">
Projects
</span>
<button
className="w-5 h-5 grid place-items-center rounded text-faint hover:text-ink hover:bg-panel"
title="New project"
aria-label="New project"
onClick={() => props.onNewProject(browseKey)}
>
<Icon name="folderPlus" size={14} />
</button>
</div>
<div className="space-y-0.5">
{projectOrder.length === 0 && (
<div className="px-2 py-1.5 text-[12px] text-faint leading-snug">
No projects yet start one with the + above.
</div>
)}
{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 (
<div key={proj}>
<div
className={
"flex items-center gap-1.5 px-1.5 py-1 rounded-lg cursor-pointer select-none hover:bg-panel " +
(isActive ? "text-ink" : "text-muted hover:text-ink")
}
onClick={() => setProjToggled((s) => toggleSet(s, proj))}
title={proj}
>
<Icon name="folder" size={15} className="shrink-0" />
<span
className={
"truncate min-w-0 text-[12.5px] " + (isActive ? "font-semibold" : "font-medium")
}
>
{baseName(proj)}
</span>
{/* Disclosure chevron sits AFTER the name (Codex parity), not leading the row. */}
<Icon
name={open ? "chevronDown" : "chevronRight"}
size={12}
className="text-faint shrink-0"
/>
</div>
{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.
<div className="space-y-0.5 pl-[19px]">
{shown.map((s) => sessionRow(s, { showTime: true }))}
{!showAll && list.length > peek && (
<button
className="px-2 py-1 text-[12px] text-faint hover:text-muted"
onClick={() => setProjShowAll((s) => toggleSet(s, proj))}
>
Show more ({list.length - peek})
</button>
)}
</div>
) : (
<div className="px-2 py-1.5 pl-[19px] text-[12px] text-faint leading-snug">
No conversations in this project yet.
</div>
))}
</div>
);
})}
</div>
</>
) : (
<div className="space-y-0.5">
{mine.filter(matches).length === 0 ? (
<div className="px-2 py-1.5 text-[12px] text-faint leading-snug">
{normalizedQuery ? "No matching conversations." : "No conversations yet."}
</div>
) : (
<>
{(personaShowAll.has(browseKey)
? mine.filter(matches)
: mine.filter(matches).slice(0, peek)
).map((s) => sessionRow(s))}
{!personaShowAll.has(browseKey) && mine.filter(matches).length > peek && (
<button
className="px-2 py-1 text-[12px] text-faint hover:text-muted"
onClick={() => setPersonaShowAll((s) => toggleSet(s, browseKey))}
>
Show more ({mine.filter(matches).length - peek})
</button>
)}
</>
)}
</div>
)}
{archived.length > 0 && (
<div className="mt-2 pt-1.5 border-t border-line">
<button
className="w-full flex items-center gap-1.5 px-1.5 py-1 rounded text-[12px] text-faint hover:text-muted"
onClick={() => setShowArchived((v) => !v)}
>
<Icon name={showArchived ? "chevronDown" : "chevronRight"} size={13} className="shrink-0" />
Archived ({archived.length})
</button>
{showArchived && (
<div className="space-y-0.5 mt-0.5">{archived.filter(matches).map((s) => sessionRow(s))}</div>
)}
</div>
)}
</div>
);
};
return (
<div
className="sidebar flex flex-col min-h-0 bg-panel border-r border-line"
onMouseLeave={props.onPeekLeave}
>
{/* 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. */}
<div className="brand px-3.5 pt-2.5 pb-2 flex items-center gap-2" data-tauri-drag-region>
{/* Collapse (dock) / pin the sidebar. ⌘B mirrors this. */}
{props.onCollapse && (
<button
className="nav-pin-btn w-7 h-7 grid place-items-center rounded-md text-faint hover:text-ink hover:bg-paper shrink-0"
title={props.collapsed ? "Dock sidebar (⌘B)" : "Collapse sidebar (⌘B)"}
aria-label={props.collapsed ? "Dock sidebar" : "Collapse sidebar"}
onClick={props.onCollapse}
>
<Icon name="sidebar" size={16} />
</button>
)}
<div className="brand-wordmark text-[15px]">OpenWorker<span className="beta-tag">BETA</span></div>
</div>
{/* 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. */}
<div className="px-3 pt-2">
<button
className="w-full text-left px-3 py-2 rounded-lg bg-accent text-white text-[13px] font-medium hover:opacity-95 flex items-center gap-2"
onClick={() => props.onNewSession(props.agent)}
>
<Icon name="plus" size={15} className="shrink-0" /> New session
</button>
</div>
{/* 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. */}
<div className="px-2.5 mt-1">
<button
className="w-full flex items-center gap-2.5 px-2.5 py-2 rounded-lg text-[13px] text-left text-muted hover:bg-paper hover:text-ink"
onClick={() => setSearchModalOpen(true)}
>
<Icon name="search" size={15} className="shrink-0" /> Search
</button>
</div>
{/* Automations: a first-class nav row (UX-023) — the account menu keeps its entry.
The badge is the cross-automation unseen-run total. */}
<div className="px-2.5 mt-1">
<button
className={
"w-full flex items-center gap-2.5 px-2.5 py-2 rounded-lg text-[13px] text-left hover:bg-paper hover:text-ink " +
(props.scheduledActive ? "text-ink bg-paper" : "text-muted")
}
data-testid="nav-automations"
onClick={props.onOpenScheduled}
>
<Icon name="clock" size={15} className="shrink-0" />
<span className="flex-1">Automations</span>
</button>
</div>
{/* Scroll area: Pinned band + the RECENT header (with group/filter control), then the body —
grouped (per-persona accordion) or flat (chronological list). */}
<div className="flex-1 overflow-y-auto px-2.5 mt-3 pb-2">
<div className="space-y-4">
{pinnedBand()}
{scheduledBand()}
<div>
{recentHeader()}
{layout === "grouped" ? (
<div className="space-y-1.5">
{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.
<div
key={s.key}
className={expanded ? "rounded-xl bg-paper/70 overflow-hidden" : ""}
>
<div
className={
"flex items-center gap-2.5 px-2 py-2 cursor-pointer select-none " +
(expanded
? ""
: isCurrent(s.key)
? "rounded-lg bg-paper"
: "rounded-lg hover:bg-paper")
}
onClick={() => onHeaderClick(s.key)}
>
<span
className={
"min-w-0 flex-1 truncate text-[13px] " +
(isCurrent(s.key) ? "font-semibold text-ink" : "font-medium text-ink")
}
>
{s.label}
</span>
<LiveDot state={liveByPersona.get(s.key)} />
<AttnBadge n={attnByPersona.get(s.key) || 0} />
{/* Persona configuration moved to Settings ▸ Personas (Rohit's call
2026-07-07) — the per-group gear read as clutter here. */}
<Icon
name={expanded ? "chevronDown" : "chevronRight"}
size={15}
className="text-faint shrink-0"
/>
</div>
{expanded && surfaceBody()}
</div>
);
})}
</div>
) : (
<div className="space-y-0.5">
{recentSessions.length === 0 ? (
<div className="px-2 py-1.5 text-[12px] text-faint leading-snug">
{normalizedQuery ? "No matching conversations." : "No conversations yet."}
</div>
) : (
<>
{(recentExpanded
? recentSessions
: recentSessions.slice(0, RECENT_PEEK)
).map((s) => cardRow(s))}
{recentSessions.length > RECENT_PEEK && (
<button
className="w-full text-left px-2 py-1.5 text-[12px] text-muted hover:text-ink"
onClick={() => setRecentExpanded((v) => !v)}
>
{recentExpanded
? "Show less"
: `Show ${recentSessions.length - RECENT_PEEK} more`}
</button>
)}
</>
)}
</div>
)}
</div>
</div>
</div>
{/* 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. */}
<div className="px-2.5 py-2 border-t border-line">
<div className="relative">
{appMenuOpen && (
<>
<div className="fixed inset-0 z-30" onClick={() => setAppMenuOpen(false)} />
<div
className="absolute z-40 bottom-full left-0 right-0 mb-1 rounded-xl border border-line bg-panel shadow-2xl py-1"
data-testid="account-menu"
role="menu"
>
{cloud?.signed_in ? (
<div
className="px-3 py-1.5 mb-1 text-[11px] text-faint truncate border-b border-line"
title={`${accountEmail} · OpenWorker Cloud`}
>
{accountEmail} · OpenWorker Cloud
</div>
) : (
<>
<div className="px-3 py-1.5 text-[11px] text-faint border-b border-line">
Not signed in one-click connections need OpenWorker Cloud
</div>
<button
className="w-full flex items-center gap-2.5 px-3 py-1.5 mb-1 text-[13px] text-left text-accent hover:bg-paper"
data-testid="account-sign-in"
onClick={async () => {
setAppMenuOpen(false);
// Opens the system browser server-side; completion lands out-of-band,
// so poll until it flips (refocusing the window also refetches).
await cloudLogin().catch(() => {});
waitForCloudSignIn((s) => {
if (s) setCloud(s);
// Other always-mounted consumers (Settings' telemetry card,
// connector panes) refetch on this.
if (s?.signed_in) announceCloudChanged();
});
}}
>
<Icon name="plug" size={15} className="shrink-0" /> Sign in to OpenWorker
Cloud
</button>
</>
)}
{appMenuItem(
"inbox",
"Inbox",
props.onOpenInbox,
props.inboxActive,
<AttnBadge n={totalAttention} />,
)}
{appMenuItem("plug", "Connectors", props.onOpenIntegrations, props.integrationsActive)}
<div className="h-px bg-line my-1 mx-2" />
{appMenuItem(
"gear",
"Settings",
props.onManage,
false,
<span className="text-[11px] text-faint"> ,</span>,
)}
{appMenuItem("clock", "Automations", props.onOpenScheduled, props.scheduledActive)}
{appMenuItem("audit", "Activity", props.onOpenAudit, props.auditActive)}
{cloud?.signed_in && (
<>
<div className="h-px bg-line my-1 mx-2" />
{appMenuItem("signOut", "Sign out", async () => {
await cloudLogout().catch(() => {});
announceCloudChanged();
})}
</>
)}
</div>
</>
)}
<button
className={
"w-full flex items-center gap-2 px-2.5 py-2 rounded-lg text-[13px] text-left " +
(appMenuOpen ? "bg-paper text-ink" : "hover:bg-paper")
}
data-testid="account-row"
onClick={() => {
if (!appMenuOpen) refreshCloud();
setAppMenuOpen((v) => !v);
}}
aria-haspopup="menu"
aria-expanded={appMenuOpen}
aria-label={cloud?.signed_in ? `Account: ${accountEmail}` : "Account: not signed in"}
>
<span
className={
"w-6 h-6 rounded-full grid place-items-center text-[10.5px] font-semibold shrink-0 " +
(cloud?.signed_in
? "bg-accentSoft text-accent"
: "bg-paper text-faint border border-line")
}
aria-hidden
>
{cloud?.signed_in ? accountName.slice(0, 1).toUpperCase() : "?"}
</span>
<span className={"truncate " + (cloud?.signed_in ? "" : "text-muted")}>
{cloud?.signed_in ? accountName : "Not signed in"}
</span>
{cloud?.signed_in && (
<span
className="w-[7px] h-[7px] rounded-full bg-ok shrink-0"
title="Signed in to OpenWorker Cloud"
aria-hidden
/>
)}
<span className="flex-1" />
{inboxUnlocked && (
<span
className={
"inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11.5px] shrink-0 cursor-pointer " +
(totalAttention > 0
? "bg-accentSoft text-accent font-semibold"
: "text-faint hover:text-ink")
}
data-testid="inbox-chip"
role="button"
aria-label={
totalAttention > 0 ? `Inbox — ${totalAttention} items need you` : "Inbox"
}
title={totalAttention > 0 ? `Inbox — ${totalAttention} items need you` : "Inbox"}
onClick={(e) => {
// The chip goes STRAIGHT to Inbox — the menu is the row's target, not the chip's.
e.stopPropagation();
setAppMenuOpen(false);
props.onOpenInbox();
}}
>
<Icon name="inbox" size={13} />
{totalAttention > 0 ? totalAttention : null}
</span>
)}
<Icon
name="chevronDown"
size={14}
className={"text-faint shrink-0 transition-transform " + (appMenuOpen ? "" : "rotate-180")}
/>
</button>
</div>
</div>
{searchModalOpen && (
<SearchModal
sessions={props.sessions}
personas={personas ?? undefined}
onSelect={(id, ws, ag) => {
setSearchModalOpen(false);
props.onSelectSession(id, ws, ag);
}}
onClose={() => setSearchModalOpen(false)}
/>
)}
</div>
);
}