import { useEffect, useState } from "react";
import {
getConnectors,
getDmRoute,
getInboxRouting,
getRecentChannels,
getSessions,
getSubscriptions,
getUnrouted,
setDmRoute,
setInboxBinding,
subscribeChannel,
unsubscribeChannel,
type RecentChannel,
type Connector,
type Subscription,
type UnroutedItem,
} from "../api";
import type { SessionInfo } from "../types";
import { ChannelPicker } from "./SubscriptionsChip";
import { Icon } from "./Icon";
// Inbox ▸ Configure (UX-DECISIONS §28): the former Connectors ▸ "Messaging routing" page,
// relocated whole — where inbox items go out (mirror channel), how inbound messages reach
// sessions (DM route, channel subscriptions), and the Unrouted dead-letter. Moving it here
// also deleted a duplication: the mirror channel used to be editable BOTH on this page and
// via an inline configurator on the Inbox list.
const CARD = "rounded-xl2 border border-line bg-panel";
const SELECT = "px-2.5 py-1.5 rounded-lg border border-line bg-paper text-[13px] text-ink";
const BTN_ACCENT_SM = "text-[12px] px-2.5 py-1 rounded-md bg-accent text-white disabled:opacity-50";
export function InboxConfigure() {
return (
{/* Unrouted = delivery FAILURES ("messages that never reached you"), so it lives with
the Inbox now (§28; previously with routing under Connectors, §26). */}
Unrouted
Inbound messages and background-turn failures nothing claimed — nothing vanishes
silently.
);
}
// Where an Unattended session's approvals/questions get mirrored as interactive buttons. Targets
// the "default" route (sessions fall back to it); pick a channel separate from any you subscribe to.
function InboxRoutingCard() {
const [recent, setRecent] = useState([]);
const [connectors, setConnectors] = useState([]);
const [target, setTarget] = useState(""); // current default-binding address, e.g. "slack:C0123"
const [draft, setDraft] = useState("");
const [error, setError] = useState(null);
const load = () => {
getRecentChannels().then(setRecent).catch(() => setRecent([]));
getConnectors().then(setConnectors).catch(() => setConnectors([]));
getInboxRouting()
.then((bs) => {
const def = bs.find((b) => b.name === "default");
setTarget(def?.channel ? `${def.channel}:${def.target}` : "");
})
.catch(() => setTarget(""));
};
useEffect(() => {
load();
const t = setInterval(load, 5000);
return () => clearInterval(t);
}, []);
const save = async () => {
const addr = draft.trim();
if (!addr) return;
// "slack:C0123" → channel="slack", target="C0123"; a bare id assumes slack.
const [platform, id] = addr.includes(":") ? addr.split(":", 2) : ["slack", addr];
const result = await setInboxBinding("default", platform, id);
if (!result.ok) {
setError(result.error || "Could not update Inbox routing.");
return;
}
setError(null);
setDraft("");
load();
};
const clear = async () => {
const result = await setInboxBinding("default", null, "");
if (!result.ok) {
setError(result.error || "Could not clear Inbox routing.");
return;
}
setError(null);
load();
};
const draftAddr = draft.trim();
const [draftPlatform, draftTarget] = draftAddr.includes(":")
? draftAddr.split(":", 2)
: ["slack", draftAddr];
const slack = connectors.find((c) => c.name === "slack");
const teamId =
draftPlatform === "slack" && draftTarget.includes("/")
? draftTarget.split("/", 1)[0]
: null;
const owners =
draftPlatform !== "slack"
? []
: teamId
? slack?.workspaces?.find((w) => w.team_id === teamId)?.approval_owner_ids ?? []
: slack?.approval_owner_ids ?? [];
const missingSlackOwner =
draftPlatform === "slack" && draftTarget.length > 0 && owners.length === 0;
// Show the channel's NAME when the recent list knows it (raw address as the fallback/tooltip).
const known = recent.find((c) => c.channel === target)?.name;
return (
Unattended approvals
Channel where an Unattended session posts Approve/Deny buttons. Currently mirroring to{" "}
{known ? `#${known}` : target || "in-app Inbox only"}
.
Set
{target && (
clear
)}
{missingSlackOwner && (
Choose an approval owner under Integrations → Slack before routing approvals here.
)}
{error &&
{error}
}
);
}
// Which session handles incoming DMs to the bot. None → DMs park in the Unrouted section below.
function DmRouteCard() {
const [sessions, setSessions] = useState([]);
const [dm, setDm] = useState("");
const load = () => {
getSessions().then(setSessions).catch(() => setSessions([]));
getDmRoute().then((s) => setDm(s || "")).catch(() => setDm(""));
};
useEffect(() => {
load();
const t = setInterval(load, 5000);
return () => clearInterval(t);
}, []);
const real = sessions.filter((s) => !s.session_id.startsWith("__"));
const choose = async (sessionId: string) => {
setDm(sessionId);
await setDmRoute(sessionId);
load();
};
return (
Direct messages
Session that handles DMs to the bot. With none, DMs park under Unrouted below.
choose(e.target.value)}>
No session — park DMs
{real.map((s) => (
{s.title || s.session_id}
))}
);
}
// Which sessions listen to which channels (inbound), and where each routes its Inbox (outbound).
// Subscriptions can be created by the agent (it asks you via ask_user) or added here directly.
function SubscriptionsCard() {
const [subs, setSubs] = useState(null);
const [sessions, setSessions] = useState([]);
const [recent, setRecent] = useState([]);
const [addSession, setAddSession] = useState("");
const [addChannel, setAddChannel] = useState("");
const load = () => {
getSubscriptions().then(setSubs).catch(() => setSubs([]));
getSessions().then(setSessions).catch(() => setSessions([]));
getRecentChannels().then(setRecent).catch(() => setRecent([]));
};
useEffect(() => {
load();
const t = setInterval(load, 5000);
return () => clearInterval(t);
}, []);
const real = sessions.filter((s) => !s.session_id.startsWith("__"));
const add = async () => {
if (!addSession || !addChannel.trim()) return;
await subscribeChannel(addSession, addChannel.trim());
setAddChannel("");
load();
};
const remove = async (sessionId: string, channel: string) => {
await unsubscribeChannel(sessionId, channel);
load();
};
return (
Channel subscriptions
— sessions that listen to a channel (inbound)
{subs && subs.length > 0 ? (
Session
Listens to
Inbox routes to
{subs.map((s, i) => (
{s.session_title}
{s.channel_name ? `#${s.channel_name}` : s.channel}
{s.channel_name && (
{s.channel}
)}
{s.collision && (
⚠ collides
)}
{s.routing_target || "—"}
remove(s.session_id, s.channel)}
>
×
))}
) : (
No channel subscriptions yet — add one below or ask a coworker to watch a channel.
)}
setAddSession(e.target.value)}
>
Choose a session…
{real.map((s) => (
{s.title || s.session_id}
))}
+ Subscribe
);
}
// Dead-letter view: inbound messages that had no destination (e.g. a DM with no session designated)
// and background turns that failed (e.g. a dead model). Read-only — for visibility/debugging.
function UnroutedTable() {
const [items, setItems] = useState(null);
useEffect(() => {
const load = () => getUnrouted().then(setItems).catch(() => setItems([]));
load();
const t = setInterval(load, 5000);
return () => clearInterval(t);
}, []);
if (items && items.length === 0)
return (
Nothing here — no dropped messages or failed turns.
);
return (
When
Source
Reason
Message
{(items ?? []).map((it, i) => (
{new Date(it.ts * 1000).toLocaleString()}
{it.source}
{it.reason}
{it.text}
))}
);
}