// AccessSection — the rail's "what can this session touch" section (§32; absorbs the §23 // Session-settings drawer and retires the topbar row/glance). One collapsible rail section: // · header: "Access" + a permanent summary ("Slack, GitHub · 2 folders") — the §23 trust // glance made ambient. Ships collapsed; expanding edits INLINE at rail width (no overlay). // · Sources — Connected toggles (per-session mute), Recommended (connect-in-context), and the // two-way connectors' channels drill-down — the drawer's content, recut. // · Folders — the session's working directories (add/remove, RO/RW gate, branch). // Owns its data (GET /v1/sessions/{id}/connections + the connector index), like the settings // row before it. Deep links (intro "Configure ›", onboarding "Start working") bump `openKey` // to expand it and scroll it into view. import { useCallback, useEffect, useRef, useState } from "react"; import { CLOUD_CHANGED, getCloudStatus, getConnectors, getRecentChannels, getSessionConnections, getSubscriptions, setSessionConnection, subscribeChannel, unsubscribeChannel, type CloudStatus, type Connector, type RecentChannel, type SessionConnections, type Subscription, } from "../api"; import { ConnectorBadge } from "../connectors/ConnectorIcon"; import { indexConnectors, labelFor, visualFor, type ConnectorMap } from "../connectors/visuals"; import { baseName } from "../paths"; import { useRoots } from "../useRoots"; import { AddFolderForm } from "./AddFolderForm"; import { Icon } from "./Icon"; import { ConnectSetup } from "./ManageTabs"; import { RootRow } from "./RootRow"; import { ChannelPicker } from "./SubscriptionsChip"; import { Toggle } from "./Toggle"; // A channel address's platform: "slack:C0123" → "slack"; a bare id or "#mention" defaults to // slack (the backend's own default when no platform prefix is given). const platformOf = (channel: string) => (channel.includes(":") ? channel.split(":")[0] : "slack"); const SEC_H = "text-[11px] uppercase tracking-[0.05em] text-faint font-semibold"; const TAG_CORE = "text-[10px] px-1.5 py-0.5 rounded-full bg-warnSoft/70 text-warnInk border border-warnInk/15"; const BTN_ACCENT = "text-[12px] px-2.5 py-1.5 rounded-lg bg-accent text-white shrink-0"; const BTN_BORDERED = "text-[12px] px-2.5 py-1.5 rounded-lg border border-line bg-paper hover:border-lineStrong shrink-0"; export function AccessSection({ sessionId, personaId, projectScoped, workspace, branch, scratchPrimary, openKey = 0, onOpenIntegrations, }: { sessionId: string; personaId?: string; // Project-scoped (code-family) sessions summarize the folder NAME, not a count. projectScoped?: boolean; workspace?: string; branch?: string | null; scratchPrimary?: boolean; // Bumped by deep links ("Configure ›", onboarding's Start-working) → expand + scroll here. openKey?: number; onOpenIntegrations?: () => void; }) { const [open, setOpen] = useState(false); const [conns, setConns] = useState(null); const [byName, setByName] = useState({}); const { roots, busy: rootsBusy, error: rootsError, addRoot, toggleAccess, removeRoot } = useRoots(sessionId, open ? 1 : 0); const rootEl = useRef(null); const reload = useCallback(() => { // personaId hint: a brand-new session has no server-side record yet, so without it the // view would resolve to the DEFAULT persona's defaults/recommends. getSessionConnections(sessionId, personaId) .then(setConns) .catch(() => setConns(null)); }, [sessionId, personaId]); useEffect(() => { reload(); }, [reload]); // The connector index feeds brand colors and gates the "Channels ·" links; refetch on every // expand so a single failed fetch at mount can't hide them for the session's whole lifetime. useEffect(() => { let live = true; getConnectors() .then((list) => live && setByName(indexConnectors(list))) .catch(() => {}); return () => { live = false; }; }, [open]); // Deep link: expand + scroll into view (ignore the mount value). const seenKey = useRef(openKey); useEffect(() => { if (openKey === seenKey.current) return; seenKey.current = openKey; setOpen(true); setTimeout(() => rootEl.current?.scrollIntoView({ block: "nearest" }), 30); }, [openKey]); // Child views (connect-in-context / channels drill-down) replace the section body inline. const [channelsFor, setChannelsFor] = useState(null); const [connectFor, setConnectFor] = useState(null); // "+ Add a source…" (§32 addendum): the FULL catalog in-session. The list shows on focus, // before any typing (FB-012: typing-to-see was a hidden step), and the query filters it // live; rich browsing (detail pages, connect states) stays on the global Connectors page. const [adding, setAdding] = useState(false); const [query, setQuery] = useState(""); // The add flow guarantees the new source is live HERE: the user asked for it in this // session, so after the connect lands it is also enabled per-session explicitly. const [addedFrom, setAddedFrom] = useState(null); // Folders mirrors Sources: flat rows + a quiet "+" link that expands the inline form. const [addingFolder, setAddingFolder] = useState(false); const [cloud, setCloud] = useState(null); useEffect(() => { if (!connectFor) return; // null means UNKNOWN (renders as "checking"), never signed-out: a single failed // fetch here used to demand sign-in from a signed-in user with no way to recover // (FB-013). Poll while the connect pane is open, keep last-good on failure, and // listen for the sign-in broadcast so the pane flips the moment login lands. const load = () => getCloudStatus().then(setCloud).catch(() => {}); load(); const t = setInterval(load, 5000); window.addEventListener(CLOUD_CHANGED, load); return () => { clearInterval(t); window.removeEventListener(CLOUD_CHANGED, load); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [!!connectFor]); const [subs, setSubs] = useState([]); const [recent, setRecent] = useState([]); const [draft, setDraft] = useState(""); const [addErr, setAddErr] = useState(null); const loadSubs = () => getSubscriptions().then(setSubs).catch(() => setSubs([])); useEffect(() => { if (!open) return; loadSubs(); getRecentChannels().then(setRecent).catch(() => setRecent([])); }, [open]); // Collapsing the section also closes any child view — reopening starts at the top level. useEffect(() => { if (!open) { setChannelsFor(null); setConnectFor(null); setAdding(false); setQuery(""); setAddedFrom(null); setAddingFolder(false); } }, [open]); const toggleSession = async (connector: string, next: boolean) => { await setSessionConnection(sessionId, connector, next); reload(); }; const channelsOf = (connector: string) => subs.filter((s) => s.session_id === sessionId && platformOf(s.channel) === connector); const addChannel = async () => { const raw = draft.trim(); if (!raw || !channelsFor) return; const channel = raw.includes(":") || raw.startsWith("#") ? raw : `${channelsFor}:${raw}`; const r = await subscribeChannel(sessionId, channel); if (!r.ok) { setAddErr(r.error || "Couldn't add that channel."); return; } setAddErr(null); setDraft(""); loadSubs(); }; const removeChannel = async (channel: string) => { await unsubscribeChannel(sessionId, channel); loadSubs(); }; const connected = conns?.connected ?? []; const recommended = conns?.recommended ?? []; const live = connected.filter((c) => c.enabled); // Catalog list: available, not already in the Connected list (those have toggles above). // Empty query = the whole catalog (FB-012 — the list renders before any typing); a query // narrows it on title/name/aliases ("calendar" must surface Outlook, not just Google // Calendar). Alphabetical so filtering never reorders; the container height-caps it, so // no count cap here. const connectedSet = new Set(connected.map((c) => c.connector)); const q = query.trim().toLowerCase(); const results = Object.values(byName) .filter( (c) => c.available && !connectedSet.has(c.name) && (!q || c.title.toLowerCase().includes(q) || c.name.toLowerCase().includes(q) || (c.aliases ?? []).some((a) => a.toLowerCase().includes(q))), ) .sort((a, b) => a.title.localeCompare(b.title)); // The header summary — the §23 glance, permanent: live source names + the folder fact. const names = live.map((c) => labelFor(c.connector, byName)); const sourcesPart = names.length === 0 ? "no sources" : names.length <= 2 ? names.join(", ") : `${names.slice(0, 2).join(", ")} +${names.length - 2}`; const folderPart = projectScoped ? baseName(workspace || roots.find((r) => r.primary)?.path || "") || null : roots.length > 0 ? `${roots.length} folder${roots.length === 1 ? "" : "s"}` : null; const summary = [sourcesPart, folderPart].filter(Boolean).join(" · "); return (
{open && (
{connectFor ? ( { const name = connectFor.name; setConnectFor(null); if (addedFrom === name) { // Added from THIS session's panel → also enable it here explicitly (a // catalog connector need not be in the persona's default-on set). setAddedFrom(null); setSessionConnection(sessionId, name, true) .catch(() => {}) .finally(reload); return; } reload(); }} onBack={() => { setConnectFor(null); setAddedFrom(null); }} /> ) : channelsFor ? ( { setDraft(v); setAddErr(null); }} onAdd={addChannel} error={addErr} onRemove={removeChannel} onBack={() => setChannelsFor(null)} /> ) : (
{/* Sources — each toggle is a per-session override (mute for THIS session only). */}
Sources
{connected.length === 0 && (
No connectors enabled for this session.
)}
{connected.map((c) => (
{labelFor(c.connector, byName)} {c.detail && · {c.detail}}
{byName[c.connector]?.channels && ( )}
toggleSession(c.connector, next)} title="Enabled for this session — tap to mute here" />
))}
{connected.length > 0 && (

Off mutes it for this session only — the connector stays connected.

)} {/* §32 addendum (owner ask 2026-07-13; FB-012): the catalog's long tail, in-session. A quiet row that becomes a typeahead: full list on focus, filter as you type. */} {adding ? (
setQuery(e.target.value)} onKeyDown={(e) => { if (e.key === "Escape") { setAdding(false); setQuery(""); } }} autoFocus data-testid="access-add-search" /> {results.length === 0 && ( // Also covers a failed/empty catalog fetch: an open picker must never // be silently blank — point at the Connectors page either way.
No match — see all on the Connectors page below.
)}
{results.map((c) => ( ))}
) : ( )} {/* Lives with its list (tester ask 2026-07-26): each group's manage link sits directly under that group, not pooled at the section's bottom. */}
{recommended.length > 0 && (
Recommended
{recommended.map((r) => (
{labelFor(r.connector, byName)} {r.tier === "core" && core}
{r.reason}
))}
)} {/* Working directories — standing session config (§22/§23 lineage). Flat rows + a quiet "+" link, structurally identical to Sources (owner ask 2026-07-13: the old drawer's card wrapper read too heavy in the rail). */}
Folders
{roots.map((r) => ( ))}
{addingFolder ? (
setAddingFolder(false)} />
) : ( )} {rootsError &&
{rootsError}
}
)}
)}
); } // Connect-in-context (§32 child view): the same ConnectSetup the global Connectors page uses, // hosted inline in the section so connecting never navigates away. Managed connects complete // out-of-band (browser → broker → sidecar), so poll until the connector flips. function ConnectInline({ c, cloud, onDone, onBack, }: { c: Connector; cloud: CloudStatus | null; onDone: () => void; onBack: () => void; }) { useEffect(() => { const t = setInterval(async () => { try { const list = await getConnectors(); if (list.find((x) => x.name === c.name)?.connected) onDone(); } catch { /* poll again */ } }, 2500); return () => clearInterval(t); }, [c.name, onDone]); return (
{c.blurb &&

{c.blurb}

}
{/* Scope semantics, stated once (owner ask 2026-07-13): connecting is account-level, the toggle above is what scopes it to a session. */}

Connecting makes {c.title} available to all your coworkers — the toggle in this list controls just this session.

); } // The per-connector channels drill-down (§32 child view): which channels THIS session listens // to on a two-way messaging connector (Slack/Telegram). function ChannelsInline({ label, channels, recent, draft, onDraft, onAdd, error, onRemove, onBack, }: { label: string; channels: Subscription[]; recent: RecentChannel[]; draft: string; onDraft: (v: string) => void; onAdd: () => void; error?: string | null; onRemove: (channel: string) => void; onBack: () => void; }) { return (
Subscribed channels · {channels.length}
{channels.length === 0 ? (
Not listening to any {label} channel yet.
) : (
{channels.map((s) => (
{s.channel_name ? `#${s.channel_name}` : s.channel} {s.collision && ( )}
))}
)}
Add a channel
{error && (

{error}

)}

The agent receives messages posted to these channels. Removing one stops this session from listening — the connector stays connected.

); }