mirror of
https://github.com/andrewyng/openworker.git
synced 2026-09-11 23:00:16 +00:00
Global & per-workspace skill. Per-persona skills will be improved later as we re-design that abstraction, as per roadmap.
607 lines
25 KiB
TypeScript
607 lines
25 KiB
TypeScript
// 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<SessionConnections | null>(null);
|
||
const [byName, setByName] = useState<ConnectorMap>({});
|
||
const { roots, busy: rootsBusy, error: rootsError, addRoot, toggleAccess, removeRoot } =
|
||
useRoots(sessionId, open ? 1 : 0);
|
||
const rootEl = useRef<HTMLElement | null>(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<string | null>(null);
|
||
const [connectFor, setConnectFor] = useState<Connector | null>(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<string | null>(null);
|
||
// Folders mirrors Sources: flat rows + a quiet "+" link that expands the inline form.
|
||
const [addingFolder, setAddingFolder] = useState(false);
|
||
const [cloud, setCloud] = useState<CloudStatus | null>(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<Subscription[]>([]);
|
||
const [recent, setRecent] = useState<RecentChannel[]>([]);
|
||
const [draft, setDraft] = useState("");
|
||
const [addErr, setAddErr] = useState<string | null>(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 (
|
||
<section className="rail-section" ref={rootEl} data-testid="access-section">
|
||
<div className="rail-section-head">
|
||
<button className="rail-section-toggle" onClick={() => setOpen((v) => !v)} data-testid="access-toggle">
|
||
<Icon name={open ? "chevronDown" : "chevronRight"} size={14} className="rail-chev" />
|
||
<span>Access</span>
|
||
<span
|
||
className="ml-auto min-w-0 truncate text-[11px] font-normal text-faint"
|
||
data-testid="access-summary"
|
||
title={summary}
|
||
>
|
||
{summary}
|
||
</span>
|
||
</button>
|
||
</div>
|
||
{open && (
|
||
<div className="rail-section-body" role="region" aria-label="Session access">
|
||
{connectFor ? (
|
||
<ConnectInline
|
||
c={connectFor}
|
||
cloud={cloud}
|
||
onDone={() => {
|
||
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 ? (
|
||
<ChannelsInline
|
||
label={labelFor(channelsFor, byName)}
|
||
channels={channelsOf(channelsFor)}
|
||
recent={recent}
|
||
draft={draft}
|
||
onDraft={(v) => {
|
||
setDraft(v);
|
||
setAddErr(null);
|
||
}}
|
||
onAdd={addChannel}
|
||
error={addErr}
|
||
onRemove={removeChannel}
|
||
onBack={() => setChannelsFor(null)}
|
||
/>
|
||
) : (
|
||
<div className="space-y-4">
|
||
{/* Sources — each toggle is a per-session override (mute for THIS session only). */}
|
||
<div>
|
||
<div className={`${SEC_H} mb-1.5`}>Sources</div>
|
||
{connected.length === 0 && (
|
||
<div className="text-[12px] text-faint py-0.5">
|
||
No connectors enabled for this session.
|
||
</div>
|
||
)}
|
||
<div className="space-y-1">
|
||
{connected.map((c) => (
|
||
<div className="flex items-center gap-2 py-1" key={c.connector}>
|
||
<ConnectorBadge connector={visualFor(c.connector, "connector", byName)} size={24} />
|
||
<div className="min-w-0 flex-1">
|
||
<div className="text-[12.5px] font-medium leading-tight truncate">
|
||
<span>{labelFor(c.connector, byName)}</span>
|
||
{c.detail && <span className="text-faint font-normal"> · {c.detail}</span>}
|
||
</div>
|
||
{byName[c.connector]?.channels && (
|
||
<button
|
||
className="inline-flex items-center gap-0.5 text-[11px] text-accent hover:underline"
|
||
onClick={() => {
|
||
setDraft("");
|
||
setChannelsFor(c.connector);
|
||
}}
|
||
>
|
||
Channels · {channelsOf(c.connector).length}
|
||
<Icon name="chevronRight" size={10} />
|
||
</button>
|
||
)}
|
||
</div>
|
||
<Toggle
|
||
checked={c.enabled}
|
||
onChange={(next) => toggleSession(c.connector, next)}
|
||
title="Enabled for this session — tap to mute here"
|
||
/>
|
||
</div>
|
||
))}
|
||
</div>
|
||
{connected.length > 0 && (
|
||
<p className="text-[10.5px] text-faint mt-1 leading-snug">
|
||
Off mutes it for <b>this session only</b> — the connector stays connected.
|
||
</p>
|
||
)}
|
||
{/* §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 ? (
|
||
<div className="mt-1.5">
|
||
<input
|
||
className="w-full px-2.5 py-1.5 rounded-lg border border-line bg-panel text-[12.5px] outline-none focus:border-accent"
|
||
placeholder="Search connectors…"
|
||
value={query}
|
||
onChange={(e) => 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.
|
||
<div className="text-[11.5px] text-faint mt-1.5 px-0.5">
|
||
No match — see all on the Connectors page below.
|
||
</div>
|
||
)}
|
||
<div className="mt-1 max-h-64 overflow-y-auto">
|
||
{results.map((c) => (
|
||
<button
|
||
key={c.name}
|
||
className="w-full flex items-center gap-2 py-1.5 px-0.5 rounded-lg text-left hover:bg-paper"
|
||
data-testid={`access-add-${c.name}`}
|
||
onClick={() => {
|
||
setAdding(false);
|
||
setQuery("");
|
||
setAddedFrom(c.name);
|
||
setConnectFor(c);
|
||
}}
|
||
>
|
||
<ConnectorBadge connector={visualFor(c.name, "connector", byName)} size={22} />
|
||
<span className="min-w-0 flex-1">
|
||
<span className="block text-[12.5px] font-medium leading-tight">
|
||
{c.title}
|
||
</span>
|
||
<span className="block text-[11px] text-faint truncate">{c.blurb}</span>
|
||
</span>
|
||
<Icon name="chevronRight" size={11} className="text-faint shrink-0" />
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<button
|
||
className="mt-1 text-[12px] text-accent hover:underline text-left"
|
||
onClick={() => setAdding(true)}
|
||
data-testid="access-add-source"
|
||
>
|
||
+ Add a source…
|
||
</button>
|
||
)}
|
||
{/* 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. */}
|
||
<button
|
||
className="mt-1.5 block text-[12px] text-accent font-medium hover:underline text-left"
|
||
onClick={() => onOpenIntegrations?.()}
|
||
>
|
||
Manage all connectors (global) →
|
||
</button>
|
||
</div>
|
||
|
||
{recommended.length > 0 && (
|
||
<div>
|
||
<div className={`${SEC_H} mb-1.5`}>Recommended</div>
|
||
<div className="space-y-1">
|
||
{recommended.map((r) => (
|
||
<div className="flex items-center gap-2 py-1" key={r.connector}>
|
||
<ConnectorBadge connector={visualFor(r.connector, "connector", byName)} size={24} />
|
||
<div className="min-w-0 flex-1">
|
||
<div className="flex items-center gap-1.5 text-[12.5px] font-medium leading-tight">
|
||
<span className="truncate">{labelFor(r.connector, byName)}</span>
|
||
{r.tier === "core" && <span className={TAG_CORE}>core</span>}
|
||
</div>
|
||
<div className="text-[11px] text-faint truncate" title={r.reason}>
|
||
{r.reason}
|
||
</div>
|
||
</div>
|
||
<button
|
||
className={r.tier === "core" ? BTN_ACCENT : BTN_BORDERED}
|
||
onClick={() => {
|
||
// Connect IN CONTEXT when we ship this connector; unknown refs
|
||
// (no descriptor) still fall back to the global page.
|
||
const desc = byName[r.connector];
|
||
if (desc) setConnectFor(desc);
|
||
else onOpenIntegrations?.();
|
||
}}
|
||
>
|
||
Connect
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 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). */}
|
||
<div data-testid="drawer-directories">
|
||
<div className={`${SEC_H} mb-1.5`}>Folders</div>
|
||
<div className="-mx-1.5">
|
||
{roots.map((r) => (
|
||
<RootRow
|
||
key={r.path}
|
||
root={r}
|
||
busy={rootsBusy}
|
||
scratchPrimary={scratchPrimary}
|
||
branch={r.primary ? branch : undefined}
|
||
onToggle={toggleAccess}
|
||
onRemove={removeRoot}
|
||
/>
|
||
))}
|
||
</div>
|
||
{addingFolder ? (
|
||
<div className="mt-1.5">
|
||
<AddFolderForm
|
||
onAdd={addRoot}
|
||
busy={rootsBusy}
|
||
startOpen
|
||
onDismiss={() => setAddingFolder(false)}
|
||
/>
|
||
</div>
|
||
) : (
|
||
<button
|
||
className="mt-1 text-[12px] text-accent hover:underline text-left"
|
||
onClick={() => setAddingFolder(true)}
|
||
>
|
||
+ Give access to a folder…
|
||
</button>
|
||
)}
|
||
{rootsError && <div className="roots-err">{rootsError}</div>}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</section>
|
||
);
|
||
}
|
||
|
||
// 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 (
|
||
<div>
|
||
<button
|
||
className="inline-flex items-center gap-1 text-[12px] text-faint hover:text-ink mb-2"
|
||
onClick={onBack}
|
||
aria-label="Back to sources"
|
||
>
|
||
<Icon name="arrowLeft" size={13} /> Connect {c.title}
|
||
</button>
|
||
{c.blurb && <p className="text-[12px] text-muted mb-1 leading-relaxed">{c.blurb}</p>}
|
||
<div className="-mx-2">
|
||
<ConnectSetup c={c} cloud={cloud} onConnected={onDone} />
|
||
</div>
|
||
{/* Scope semantics, stated once (owner ask 2026-07-13): connecting is account-level,
|
||
the toggle above is what scopes it to a session. */}
|
||
<p className="text-[10.5px] text-faint mt-2 leading-snug">
|
||
Connecting makes {c.title} available to all your coworkers — the toggle in this list
|
||
controls just this session.
|
||
</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// 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 (
|
||
<div>
|
||
<button
|
||
className="inline-flex items-center gap-1 text-[12px] text-faint hover:text-ink mb-2"
|
||
onClick={onBack}
|
||
aria-label="Back to sources"
|
||
>
|
||
<Icon name="arrowLeft" size={13} /> {label} channels
|
||
</button>
|
||
<div className={`${SEC_H} mb-1.5`}>Subscribed channels · {channels.length}</div>
|
||
{channels.length === 0 ? (
|
||
<div className="text-[12px] text-faint py-0.5">
|
||
Not listening to any {label} channel yet.
|
||
</div>
|
||
) : (
|
||
<div className="space-y-1">
|
||
{channels.map((s) => (
|
||
<div className="flex items-center gap-1.5 py-1" key={s.channel}>
|
||
<Icon name="plug" size={13} className="text-muted shrink-0" />
|
||
<span className="min-w-0 flex-1 text-[12.5px] truncate" title={s.channel}>
|
||
{s.channel_name ? `#${s.channel_name}` : s.channel}
|
||
</span>
|
||
{s.collision && (
|
||
<span
|
||
className="text-[10.5px] text-warnInk bg-warnSoft/70 border border-warnInk/15 rounded px-1 shrink-0"
|
||
title="This channel is also this session's Inbox-routing target — inbound and outbound collide."
|
||
>
|
||
⚠
|
||
</span>
|
||
)}
|
||
<button
|
||
className="w-5 h-5 grid place-items-center text-faint hover:text-danger shrink-0"
|
||
title="Stop listening"
|
||
onClick={() => onRemove(s.channel)}
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
<div className={`${SEC_H} mt-3 mb-1.5`}>Add a channel</div>
|
||
<div className="flex items-center gap-1.5">
|
||
<ChannelPicker value={draft} onChange={onDraft} recent={recent} onSubmit={onAdd} />
|
||
<button className={BTN_ACCENT} disabled={!draft.trim()} onClick={onAdd}>
|
||
Add
|
||
</button>
|
||
</div>
|
||
{error && (
|
||
<p className="text-[11px] text-warnInk mt-1.5 leading-snug" data-testid="channel-add-error">
|
||
{error}
|
||
</p>
|
||
)}
|
||
<p className="text-[10.5px] text-faint mt-1.5 leading-snug">
|
||
The agent receives messages posted to these channels. Removing one stops this session
|
||
from listening — the connector stays connected.
|
||
</p>
|
||
</div>
|
||
);
|
||
}
|