diff --git a/README.md b/README.md index 2808df807..3d0f1ec12 100644 --- a/README.md +++ b/README.md @@ -434,6 +434,12 @@ token = "env:DISCORD_BOT_TOKEN" agent_id = "my-agent" channel = "discord" guild_id = "your-discord-guild-id" + +# Optional: route a named adapter instance +[[bindings]] +agent_id = "my-agent" +channel = "discord" +adapter = "ops" ``` ```bash diff --git a/docs/content/docs/(configuration)/config.mdx b/docs/content/docs/(configuration)/config.mdx index 7c6350003..1ef0bb116 100644 --- a/docs/content/docs/(configuration)/config.mdx +++ b/docs/content/docs/(configuration)/config.mdx @@ -596,16 +596,79 @@ Channel/worker temporal context timezone precedence is: |-----|------|---------|-------------| | `enabled` | bool | false | Enable Discord adapter | | `token` | string | None | Bot token (or `env:VAR_NAME`) | +| `instances` | table[] | [] | Optional named Discord bot instances | | `dm_allowed_users` | string[] | [] | User IDs allowed to DM the bot | +### `[[messaging.discord.instances]]` + +| Key | Type | Default | Description | +|-----|------|---------|-------------| +| `name` | string | **required** | Instance selector used by bindings (`adapter = "name"`) | +| `enabled` | bool | true | Enable this named instance | +| `token` | string | **required** | Bot token (or `env:VAR_NAME`) | +| `dm_allowed_users` | string[] | [] | User IDs allowed to DM this instance | +| `allow_bot_messages` | bool | false | Whether this instance accepts bot-authored messages | + +### `[messaging.slack]` + +| Key | Type | Default | Description | +|-----|------|---------|-------------| +| `enabled` | bool | false | Enable Slack adapter | +| `bot_token` | string | None | Bot token (or `env:VAR_NAME`) | +| `app_token` | string | None | App-level token (or `env:VAR_NAME`) | +| `instances` | table[] | [] | Optional named Slack app instances | +| `dm_allowed_users` | string[] | [] | Slack user IDs allowed to DM the bot | + +### `[[messaging.slack.instances]]` + +| Key | Type | Default | Description | +|-----|------|---------|-------------| +| `name` | string | **required** | Instance selector used by bindings (`adapter = "name"`) | +| `enabled` | bool | true | Enable this named instance | +| `bot_token` | string | **required** | Bot token (or `env:VAR_NAME`) | +| `app_token` | string | **required** | App-level token (or `env:VAR_NAME`) | +| `dm_allowed_users` | string[] | [] | Slack user IDs allowed to DM this instance | + ### `[messaging.telegram]` | Key | Type | Default | Description | |-----|------|---------|-------------| | `enabled` | bool | false | Enable Telegram adapter | | `token` | string | None | Bot token from @BotFather (or `env:VAR_NAME`). Falls back to `TELEGRAM_BOT_TOKEN` env var | +| `instances` | table[] | [] | Optional named Telegram bot instances | | `dm_allowed_users` | string[] | [] | User IDs allowed to DM the bot. Empty = DMs from anyone accepted | +### `[[messaging.telegram.instances]]` + +| Key | Type | Default | Description | +|-----|------|---------|-------------| +| `name` | string | **required** | Instance selector used by bindings (`adapter = "name"`) | +| `enabled` | bool | true | Enable this named instance | +| `token` | string | **required** | Bot token (or `env:VAR_NAME`) | +| `dm_allowed_users` | string[] | [] | User IDs allowed to DM this instance | + +### `[messaging.twitch]` + +| Key | Type | Default | Description | +|-----|------|---------|-------------| +| `enabled` | bool | false | Enable Twitch adapter | +| `username` | string | None | Bot login username | +| `oauth_token` | string | None | OAuth token (`oauth:...` or plain token) | +| `instances` | table[] | [] | Optional named Twitch bot instances | +| `channels` | string[] | [] | Channels to join | +| `trigger_prefix` | string | None | Optional prefix required to trigger replies | + +### `[[messaging.twitch.instances]]` + +| Key | Type | Default | Description | +|-----|------|---------|-------------| +| `name` | string | **required** | Instance selector used by bindings (`adapter = "name"`) | +| `enabled` | bool | true | Enable this named instance | +| `username` | string | **required** | Bot login username | +| `oauth_token` | string | **required** | OAuth token (`oauth:...` or plain token) | +| `channels` | string[] | [] | Channels to join for this instance | +| `trigger_prefix` | string | None | Optional prefix required to trigger replies | + ### `[messaging.email]` | Key | Type | Default | Description | @@ -629,6 +692,30 @@ Channel/worker temporal context timezone precedence is: | `max_body_bytes` | integer | 262144 | Max inbound body bytes before truncation | | `max_attachment_bytes` | integer | 10485760 | Max attachment bytes to process metadata for | +### `[[messaging.email.instances]]` + +| Key | Type | Default | Description | +|-----|------|---------|-------------| +| `name` | string | **required** | Instance selector used by bindings (`adapter = "name"`) | +| `enabled` | bool | true | Enable this named instance | +| `imap_host` | string | **required** | IMAP host (or `env:VAR_NAME`) | +| `imap_port` | integer | 993 | IMAP port | +| `imap_username` | string | **required** | IMAP username (or `env:VAR_NAME`) | +| `imap_password` | string | **required** | IMAP password (or `env:VAR_NAME`) | +| `imap_use_tls` | bool | true | Use direct TLS for IMAP | +| `smtp_host` | string | **required** | SMTP host (or `env:VAR_NAME`) | +| `smtp_port` | integer | 587 | SMTP port | +| `smtp_username` | string | None | SMTP username (defaults to IMAP username) | +| `smtp_password` | string | None | SMTP password (defaults to IMAP password) | +| `smtp_use_starttls` | bool | true | Use STARTTLS for SMTP | +| `from_address` | string | None | Sender address (defaults to SMTP username) | +| `from_name` | string | None | Optional sender display name | +| `poll_interval_secs` | integer | 30 | How often to check for new email | +| `folders` | string[] | `["INBOX"]` | IMAP folders to poll | +| `allowed_senders` | string[] | `[]` | Optional allowlist (empty = all) | +| `max_body_bytes` | integer | 262144 | Max inbound body bytes | +| `max_attachment_bytes` | integer | 10485760 | Max attachment bytes | + ### `[messaging.webhook]` | Key | Type | Default | Description | @@ -645,6 +732,7 @@ Routes platform conversations to agents. Checked in order; first match wins. Unm |-----|------|---------|-------------| | `agent_id` | string | **required** | Which agent handles matched messages | | `channel` | string | **required** | Platform name (`discord`, `slack`, `telegram`, `twitch`, `email`, `webhook`) | +| `adapter` | string | None | Optional named adapter selector (e.g. `ops` => `discord:ops`) | | `guild_id` | string | None | Discord guild filter | | `chat_id` | string | None | Telegram chat filter | | `channel_ids` | string[] | [] | Discord channel ID filter (includes threads in those channels) | diff --git a/docs/content/docs/(messaging)/messaging.mdx b/docs/content/docs/(messaging)/messaging.mdx index 4804d3148..2856c4dbe 100644 --- a/docs/content/docs/(messaging)/messaging.mdx +++ b/docs/content/docs/(messaging)/messaging.mdx @@ -44,6 +44,7 @@ Go to **Settings** → **Bindings** tab to create and manage bindings. Each binding specifies: - Which **agent** handles the messages - Which **platform** (Discord, Slack, Telegram, Twitch, Email) +- Optionally which **adapter instance** on that platform - Optionally which **server/workspace/chat** to scope it to - Optionally which **channels** within that server @@ -57,6 +58,13 @@ agent_id = "main" channel = "discord" guild_id = "123456789" +# Route only the named Discord adapter instance `ops` +[[bindings]] +agent_id = "main" +channel = "discord" +adapter = "ops" +guild_id = "987654321" + # Route a Slack workspace to the main agent [[bindings]] agent_id = "main" @@ -75,6 +83,8 @@ chat_id = "-100123456789" Without any filtering (no guild ID, workspace ID, or chat ID), a binding accepts messages from anywhere on that platform. +Named adapters use `adapter = ""` in bindings. Omit `adapter` to target the platform default adapter (`discord`, `slack`, `telegram`, `twitch`, `email`, `webhook`). + If no binding matches an incoming message, it's routed to the default agent automatically. This means the bot responds everywhere out of the box — add bindings to restrict it to specific channels or servers. ## Multiple Agents diff --git a/interface/src/api/client.ts b/interface/src/api/client.ts index f329b9432..620efe03d 100644 --- a/interface/src/api/client.ts +++ b/interface/src/api/client.ts @@ -952,6 +952,15 @@ export interface PlatformStatus { enabled: boolean; } +export interface AdapterInstanceStatus { + platform: string; + name: string | null; + runtime_key: string; + configured: boolean; + enabled: boolean; + binding_count: number; +} + export interface MessagingStatusResponse { discord: PlatformStatus; slack: PlatformStatus; @@ -959,11 +968,52 @@ export interface MessagingStatusResponse { webhook: PlatformStatus; twitch: PlatformStatus; email: PlatformStatus; + instances: AdapterInstanceStatus[]; +} + +export interface CreateMessagingInstanceRequest { + platform: string; + name?: string; + enabled?: boolean; + credentials: { + discord_token?: string; + slack_bot_token?: string; + slack_app_token?: string; + telegram_token?: string; + twitch_username?: string; + twitch_oauth_token?: string; + twitch_client_id?: string; + twitch_client_secret?: string; + twitch_refresh_token?: string; + email_imap_host?: string; + email_imap_port?: number; + email_imap_username?: string; + email_imap_password?: string; + email_smtp_host?: string; + email_smtp_port?: number; + email_smtp_username?: string; + email_smtp_password?: string; + email_from_address?: string; + webhook_port?: number; + webhook_bind?: string; + webhook_auth_token?: string; + }; +} + +export interface DeleteMessagingInstanceRequest { + platform: string; + name?: string; +} + +export interface MessagingInstanceActionResponse { + success: boolean; + message: string; } export interface BindingInfo { agent_id: string; channel: string; + adapter: string | null; guild_id: string | null; workspace_id: string | null; chat_id: string | null; @@ -979,6 +1029,7 @@ export interface BindingsListResponse { export interface CreateBindingRequest { agent_id: string; channel: string; + adapter?: string; guild_id?: string; workspace_id?: string; chat_id?: string; @@ -1017,11 +1068,13 @@ export interface CreateBindingResponse { export interface UpdateBindingRequest { original_agent_id: string; original_channel: string; + original_adapter?: string; original_guild_id?: string; original_workspace_id?: string; original_chat_id?: string; agent_id: string; channel: string; + adapter?: string; guild_id?: string; workspace_id?: string; chat_id?: string; @@ -1038,6 +1091,7 @@ export interface UpdateBindingResponse { export interface DeleteBindingRequest { agent_id: string; channel: string; + adapter?: string; guild_id?: string; workspace_id?: string; chat_id?: string; @@ -1564,11 +1618,13 @@ export const api = { return response.json() as Promise; }, - togglePlatform: async (platform: string, enabled: boolean) => { + togglePlatform: async (platform: string, enabled: boolean, adapter?: string) => { + const body: Record = { platform, enabled }; + if (adapter) body.adapter = adapter; const response = await fetch(`${API_BASE}/messaging/toggle`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ platform, enabled }), + body: JSON.stringify(body), }); if (!response.ok) { throw new Error(`API error: ${response.status}`); @@ -1576,11 +1632,13 @@ export const api = { return response.json() as Promise<{ success: boolean; message: string }>; }, - disconnectPlatform: async (platform: string) => { + disconnectPlatform: async (platform: string, adapter?: string) => { + const body: Record = { platform }; + if (adapter) body.adapter = adapter; const response = await fetch(`${API_BASE}/messaging/disconnect`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ platform }), + body: JSON.stringify(body), }); if (!response.ok) { throw new Error(`API error: ${response.status}`); @@ -1588,6 +1646,30 @@ export const api = { return response.json() as Promise<{ success: boolean; message: string }>; }, + createMessagingInstance: async (request: CreateMessagingInstanceRequest) => { + const response = await fetch(`${API_BASE}/messaging/instances`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(request), + }); + if (!response.ok) { + throw new Error(`API error: ${response.status}`); + } + return response.json() as Promise; + }, + + deleteMessagingInstance: async (request: DeleteMessagingInstanceRequest) => { + const response = await fetch(`${API_BASE}/messaging/instances`, { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(request), + }); + if (!response.ok) { + throw new Error(`API error: ${response.status}`); + } + return response.json() as Promise; + }, + // Global Settings API globalSettings: () => fetchJson("/settings"), diff --git a/interface/src/components/ChannelSettingCard.tsx b/interface/src/components/ChannelSettingCard.tsx index 55dae6906..2ce506ea9 100644 --- a/interface/src/components/ChannelSettingCard.tsx +++ b/interface/src/components/ChannelSettingCard.tsx @@ -1,7 +1,12 @@ import {useState} from "react"; import {AnimatePresence, motion} from "framer-motion"; import {useMutation, useQuery, useQueryClient} from "@tanstack/react-query"; -import {api, type PlatformStatus, type BindingInfo} from "@/api/client"; +import { + api, + type AdapterInstanceStatus, + type BindingInfo, + type CreateMessagingInstanceRequest, +} from "@/api/client"; import { Button, Input, @@ -20,42 +25,101 @@ import { import {PlatformIcon} from "@/lib/platformIcons"; import {TagInput} from "@/components/TagInput"; import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; -import {faChevronDown} from "@fortawesome/free-solid-svg-icons"; +import {faChevronDown, faPlus} from "@fortawesome/free-solid-svg-icons"; type Platform = "discord" | "slack" | "telegram" | "twitch" | "email" | "webhook"; -interface ChannelSettingCardProps { - platform: Platform; - name: string; - description: string; - status?: PlatformStatus; +const PLATFORM_LABELS: Record = { + discord: "Discord", + slack: "Slack", + telegram: "Telegram", + twitch: "Twitch", + email: "Email", + webhook: "Webhook", +}; + +const DOC_LINKS: Partial> = { + discord: "https://docs.spacebot.sh/discord-setup", + slack: "https://docs.spacebot.sh/slack-setup", + telegram: "https://docs.spacebot.sh/telegram-setup", + twitch: "https://docs.spacebot.sh/twitch-setup", +}; + +// --- Platform Catalog (Left Column) --- + +interface PlatformCatalogProps { + onAddInstance: (platform: Platform) => void; +} + +export function PlatformCatalog({onAddInstance}: PlatformCatalogProps) { + const PLATFORMS: {platform: Platform; description: string}[] = [ + {platform: "discord", description: "Discord bot integration"}, + {platform: "slack", description: "Slack bot integration"}, + {platform: "telegram", description: "Telegram bot integration"}, + {platform: "twitch", description: "Twitch chat integration"}, + {platform: "email", description: "IMAP/SMTP email integration"}, + {platform: "webhook", description: "HTTP webhook receiver"}, + ]; + + const COMING_SOON = [ + {platform: "whatsapp", name: "WhatsApp"}, + {platform: "matrix", name: "Matrix"}, + {platform: "imessage", name: "iMessage"}, + {platform: "irc", name: "IRC"}, + {platform: "lark", name: "Lark"}, + {platform: "dingtalk", name: "DingTalk"}, + ]; + + return ( +
+

+ Available +

+ {PLATFORMS.map(({platform, description}) => ( + + ))} + +

+ Coming Soon +

+ {COMING_SOON.map(({platform, name}) => ( +
+ + {name} +
+ ))} +
+ ); +} + +// --- Instance Card (Right Column) --- + +interface InstanceCardProps { + instance: AdapterInstanceStatus; expanded: boolean; - onToggle: () => void; + onToggleExpand: () => void; } -export function ChannelSettingCard({ - platform, - name, - description, - status, - expanded, - onToggle, -}: ChannelSettingCardProps) { +export function InstanceCard({instance, expanded, onToggleExpand}: InstanceCardProps) { const queryClient = useQueryClient(); - const configured = status?.configured ?? false; - const enabled = status?.enabled ?? false; - - const [credentialInputs, setCredentialInputs] = useState< - Record - >({}); - const [message, setMessage] = useState<{ - text: string; - type: "success" | "error"; - } | null>(null); - const [confirmDisconnect, setConfirmDisconnect] = useState(false); - const [editingBinding, setEditingBinding] = useState( - null, - ); + const [message, setMessage] = useState<{text: string; type: "success" | "error"} | null>(null); + const [confirmRemove, setConfirmRemove] = useState(false); + const [editingBinding, setEditingBinding] = useState(null); const [addingBinding, setAddingBinding] = useState(false); const [bindingForm, setBindingForm] = useState({ agent_id: "main", @@ -67,11 +131,16 @@ export function ChannelSettingCard({ dm_allowed_users: [] as string[], }); + const platform = instance.platform as Platform; + const instanceLabel = instance.name + ? `${PLATFORM_LABELS[platform]} "${instance.name}"` + : PLATFORM_LABELS[platform]; + const {data: bindingsData} = useQuery({ queryKey: ["bindings"], queryFn: () => api.bindings(), staleTime: 5_000, - enabled: expanded && configured, + enabled: expanded, }); const {data: agentsData} = useQuery({ @@ -81,35 +150,35 @@ export function ChannelSettingCard({ enabled: expanded, }); - const platformBindings = - bindingsData?.bindings?.filter((b) => b.channel === platform) ?? []; + // Filter bindings for this specific instance + const instanceBindings = (bindingsData?.bindings ?? []).filter((binding) => { + if (binding.channel !== platform) return false; + if (instance.name === null) return !binding.adapter; + return binding.adapter === instance.name; + }); const toggleEnabled = useMutation({ mutationFn: (newEnabled: boolean) => - api.togglePlatform(platform, newEnabled), + api.togglePlatform(platform, newEnabled, instance.name ?? undefined), onSuccess: () => { queryClient.invalidateQueries({queryKey: ["messaging-status"]}); }, - onError: (error) => - setMessage({text: `Failed: ${error.message}`, type: "error"}), + onError: (error) => setMessage({text: `Failed: ${error.message}`, type: "error"}), }); - // --- Mutations --- - - const saveCreds = useMutation({ - mutationFn: api.createBinding, + const deleteInstance = useMutation({ + mutationFn: () => + api.deleteMessagingInstance({platform, name: instance.name ?? undefined}), onSuccess: (result) => { if (result.success) { - setCredentialInputs({}); - setMessage({text: result.message, type: "success"}); + setConfirmRemove(false); queryClient.invalidateQueries({queryKey: ["messaging-status"]}); queryClient.invalidateQueries({queryKey: ["bindings"]}); } else { setMessage({text: result.message, type: "error"}); } }, - onError: (error) => - setMessage({text: `Failed: ${error.message}`, type: "error"}), + onError: (error) => setMessage({text: `Failed: ${error.message}`, type: "error"}), }); const addBindingMutation = useMutation({ @@ -120,12 +189,12 @@ export function ChannelSettingCard({ resetBindingForm(); setMessage({text: result.message, type: "success"}); queryClient.invalidateQueries({queryKey: ["bindings"]}); + queryClient.invalidateQueries({queryKey: ["messaging-status"]}); } else { setMessage({text: result.message, type: "error"}); } }, - onError: (error) => - setMessage({text: `Failed: ${error.message}`, type: "error"}), + onError: (error) => setMessage({text: `Failed: ${error.message}`, type: "error"}), }); const updateBindingMutation = useMutation({ @@ -136,12 +205,12 @@ export function ChannelSettingCard({ resetBindingForm(); setMessage({text: result.message, type: "success"}); queryClient.invalidateQueries({queryKey: ["bindings"]}); + queryClient.invalidateQueries({queryKey: ["messaging-status"]}); } else { setMessage({text: result.message, type: "error"}); } }, - onError: (error) => - setMessage({text: `Failed: ${error.message}`, type: "error"}), + onError: (error) => setMessage({text: `Failed: ${error.message}`, type: "error"}), }); const deleteBindingMutation = useMutation({ @@ -150,24 +219,12 @@ export function ChannelSettingCard({ if (result.success) { setMessage({text: result.message, type: "success"}); queryClient.invalidateQueries({queryKey: ["bindings"]}); + queryClient.invalidateQueries({queryKey: ["messaging-status"]}); } else { setMessage({text: result.message, type: "error"}); } }, - onError: (error) => - setMessage({text: `Failed: ${error.message}`, type: "error"}), - }); - - const disconnectMutation = useMutation({ - mutationFn: () => api.disconnectPlatform(platform), - onSuccess: () => { - setConfirmDisconnect(false); - setMessage(null); - queryClient.invalidateQueries({queryKey: ["messaging-status"]}); - queryClient.invalidateQueries({queryKey: ["bindings"]}); - }, - onError: (error) => - setMessage({text: `Failed: ${error.message}`, type: "error"}), + onError: (error) => setMessage({text: `Failed: ${error.message}`, type: "error"}), }); function resetBindingForm() { @@ -182,90 +239,15 @@ export function ChannelSettingCard({ }); } - function handleSaveCredentials() { - const request: any = {agent_id: "main", channel: platform}; - if (platform === "discord") { - if (!credentialInputs.discord_token?.trim()) return; - request.platform_credentials = { - discord_token: credentialInputs.discord_token.trim(), - }; - } else if (platform === "slack") { - if ( - !credentialInputs.slack_bot_token?.trim() || - !credentialInputs.slack_app_token?.trim() - ) - return; - request.platform_credentials = { - slack_bot_token: credentialInputs.slack_bot_token.trim(), - slack_app_token: credentialInputs.slack_app_token.trim(), - }; - } else if (platform === "telegram") { - if (!credentialInputs.telegram_token?.trim()) return; - request.platform_credentials = { - telegram_token: credentialInputs.telegram_token.trim(), - }; - } else if (platform === "twitch") { - if (!credentialInputs.twitch_username?.trim() || !credentialInputs.twitch_oauth_token?.trim()) return; - request.platform_credentials = { - twitch_username: credentialInputs.twitch_username.trim(), - twitch_oauth_token: credentialInputs.twitch_oauth_token.trim(), - twitch_client_id: credentialInputs.twitch_client_id?.trim(), - twitch_client_secret: credentialInputs.twitch_client_secret?.trim(), - twitch_refresh_token: credentialInputs.twitch_refresh_token?.trim(), - }; - } else if (platform === "email") { - if ( - !credentialInputs.email_imap_host?.trim() || - !credentialInputs.email_imap_username?.trim() || - !credentialInputs.email_imap_password || - !credentialInputs.email_smtp_host?.trim() || - !credentialInputs.email_smtp_username?.trim() || - !credentialInputs.email_smtp_password || - !credentialInputs.email_from_address?.trim() - ) - return; - - const parsePort = (rawPort?: string): number | undefined => { - const value = rawPort?.trim(); - if (!value) return undefined; - if (!/^\d+$/.test(value)) return Number.NaN; - - const port = Number(value); - if (!Number.isInteger(port) || port < 1 || port > 65535) - return Number.NaN; - - return port; - }; - - const parsedImapPort = parsePort(credentialInputs.email_imap_port); - const parsedSmtpPort = parsePort(credentialInputs.email_smtp_port); - - if (Number.isNaN(parsedImapPort) || Number.isNaN(parsedSmtpPort)) { - setMessage({ - text: "Ports must be integers between 1 and 65535.", - type: "error", - }); - return; - } - - request.platform_credentials = { - email_imap_host: credentialInputs.email_imap_host.trim(), - email_imap_port: parsedImapPort, - email_imap_username: credentialInputs.email_imap_username.trim(), - email_imap_password: credentialInputs.email_imap_password, - email_smtp_host: credentialInputs.email_smtp_host.trim(), - email_smtp_port: parsedSmtpPort, - email_smtp_username: credentialInputs.email_smtp_username.trim(), - email_smtp_password: credentialInputs.email_smtp_password, - email_from_address: credentialInputs.email_from_address.trim(), - email_from_name: credentialInputs.email_from_name?.trim() || undefined, - }; - } - saveCreds.mutate(request); - } - function handleAddBinding() { - const request: any = {agent_id: bindingForm.agent_id, channel: platform}; + const request: Record = { + agent_id: bindingForm.agent_id, + channel: platform, + }; + // Auto-populate adapter for named instances + if (instance.name) { + request.adapter = instance.name; + } if (platform === "discord" && bindingForm.guild_id.trim()) request.guild_id = bindingForm.guild_id.trim(); if (platform === "slack" && bindingForm.workspace_id.trim()) @@ -278,20 +260,24 @@ export function ChannelSettingCard({ request.require_mention = true; if (bindingForm.dm_allowed_users.length > 0) request.dm_allowed_users = bindingForm.dm_allowed_users; - addBindingMutation.mutate(request); + addBindingMutation.mutate(request as any); } function handleUpdateBinding() { if (!editingBinding) return; - const request: any = { + const request: Record = { original_agent_id: editingBinding.agent_id, original_channel: editingBinding.channel, + original_adapter: editingBinding.adapter || undefined, original_guild_id: editingBinding.guild_id || undefined, original_workspace_id: editingBinding.workspace_id || undefined, original_chat_id: editingBinding.chat_id || undefined, agent_id: bindingForm.agent_id, channel: platform, }; + if (instance.name) { + request.adapter = instance.name; + } if (platform === "discord" && bindingForm.guild_id.trim()) request.guild_id = bindingForm.guild_id.trim(); if (platform === "slack" && bindingForm.workspace_id.trim()) @@ -299,18 +285,21 @@ export function ChannelSettingCard({ if (platform === "telegram" && bindingForm.chat_id.trim()) request.chat_id = bindingForm.chat_id.trim(); request.channel_ids = bindingForm.channel_ids; - request.require_mention = - platform === "discord" ? bindingForm.require_mention : false; + request.require_mention = platform === "discord" ? bindingForm.require_mention : false; request.dm_allowed_users = bindingForm.dm_allowed_users; - updateBindingMutation.mutate(request); + updateBindingMutation.mutate(request as any); } function handleDeleteBinding(binding: BindingInfo) { - const request: any = {agent_id: binding.agent_id, channel: binding.channel}; + const request: Record = { + agent_id: binding.agent_id, + channel: binding.channel, + }; + if (binding.adapter) request.adapter = binding.adapter; if (binding.guild_id) request.guild_id = binding.guild_id; if (binding.workspace_id) request.workspace_id = binding.workspace_id; if (binding.chat_id) request.chat_id = binding.chat_id; - deleteBindingMutation.mutate(request); + deleteBindingMutation.mutate(request as any); } function startEditBinding(binding: BindingInfo) { @@ -331,29 +320,29 @@ export function ChannelSettingCard({ return (
- {/* Header — always visible, acts as toggle */} -
- +
- {name} - {configured && ( - - {enabled ? "● Active" : "○ Disabled"} - - )} + + {PLATFORM_LABELS[platform]} + + + {instance.name || "default"} + + + {instance.enabled ? "● Active" : "○ Disabled"} +
-

{description}

+

+ {instance.binding_count} binding{instance.binding_count !== 1 ? "s" : ""} +

-
+ {/* Expanded content */} @@ -374,67 +363,126 @@ export function ChannelSettingCard({ transition={{duration: 0.25, ease: [0.4, 0, 0.2, 1]}} className="overflow-hidden" > -
+
{/* Enable/Disable toggle */} - {configured && ( +
+
+ Enabled +

+ {instance.enabled ? "Receiving messages" : "Adapter paused"} +

+
+ toggleEnabled.mutate(checked)} + disabled={toggleEnabled.isPending} + /> +
+ + {/* Bindings section (scoped to this instance) */} +
-
- - Enabled - -

- {enabled - ? `${name} is receiving messages` - : `${name} is disconnected`} -

-
- toggleEnabled.mutate(checked)} - disabled={toggleEnabled.isPending} - /> +

Bindings

+
- )} - {/* Credentials */} - - {/* Bindings (only when connected) */} - {configured && ( - { - setAddingBinding(true); - setEditingBinding(null); - resetBindingForm(); - setMessage(null); - }} - onStartEdit={startEditBinding} - onCancelEdit={() => { - setEditingBinding(null); - setAddingBinding(false); - setMessage(null); + {instanceBindings.length > 0 ? ( +
+ {instanceBindings.map((binding, idx) => ( +
+
+ {binding.agent_id} +
+ {binding.guild_id && Guild: {binding.guild_id}} + {binding.workspace_id && Workspace: {binding.workspace_id}} + {binding.chat_id && Chat: {binding.chat_id}} + {binding.channel_ids.length > 0 && ( + + {binding.channel_ids.length} channel{binding.channel_ids.length > 1 ? "s" : ""} + + )} + {binding.dm_allowed_users.length > 0 && ( + + {binding.dm_allowed_users.length} DM user{binding.dm_allowed_users.length > 1 ? "s" : ""} + + )} + {binding.require_mention && Mention only} + {!binding.guild_id && + !binding.workspace_id && + !binding.chat_id && + binding.channel_ids.length === 0 && ( + All conversations + )} +
+
+ + +
+ ))} +
+ ) : ( +

+ No bindings. Messages will route to the default agent. +

+ )} + + {/* Add/Edit binding modal */} + { + if (!open) { + setEditingBinding(null); + setAddingBinding(false); + setMessage(null); + } }} - onAdd={handleAddBinding} - onUpdate={handleUpdateBinding} - onDelete={handleDeleteBinding} - addPending={addBindingMutation.isPending} - updatePending={updateBindingMutation.isPending} - deletePending={deleteBindingMutation.isPending} - /> - )} + > + + + + {editingBinding ? "Edit Binding" : "Add Binding"} + + + { + setEditingBinding(null); + setAddingBinding(false); + setMessage(null); + }} + saving={editingBinding ? updateBindingMutation.isPending : addBindingMutation.isPending} + /> + + +
{/* Status message */} {message && ( @@ -449,16 +497,38 @@ export function ChannelSettingCard({
)} - {/* Disconnect */} - {configured && platform !== "webhook" && ( - disconnectMutation.mutate()} - disconnecting={disconnectMutation.isPending} - /> - )} + {/* Remove instance */} +
+ {!confirmRemove ? ( + + ) : ( +
+

+ This will remove credentials and bindings for {instanceLabel}. + The adapter will stop immediately. +

+
+ + +
+
+ )} +
)} @@ -467,637 +537,430 @@ export function ChannelSettingCard({ ); } -// --- Disabled card for coming soon platforms --- +// --- Add Instance Inline Card --- -interface DisabledChannelCardProps { - platform: string; - name: string; - description: string; +interface AddInstanceCardProps { + platform: Platform; + isDefault: boolean; + onCancel: () => void; + onCreated: () => void; } -export function DisabledChannelCard({ - platform, - name, - description, -}: DisabledChannelCardProps) { - return ( -
-
- -
- {name} -

{description}

-
- -
-
- ); -} +export function AddInstanceCard({platform, isDefault, onCancel, onCreated}: AddInstanceCardProps) { + const queryClient = useQueryClient(); + const [instanceName, setInstanceName] = useState(""); + const [credentialInputs, setCredentialInputs] = useState>({}); + const [message, setMessage] = useState<{text: string; type: "success" | "error"} | null>(null); -// --- Sub-sections --- + const createInstance = useMutation({ + mutationFn: api.createMessagingInstance, + onSuccess: (result) => { + if (result.success) { + queryClient.invalidateQueries({queryKey: ["messaging-status"]}); + queryClient.invalidateQueries({queryKey: ["bindings"]}); + onCreated(); + } else { + setMessage({text: result.message, type: "error"}); + } + }, + onError: (error) => setMessage({text: `Failed: ${error.message}`, type: "error"}), + }); -function CredentialsSection({ - platform, - configured, - credentialInputs, - setCredentialInputs, - onSave, - saving, -}: { - platform: Platform; - configured: boolean; - credentialInputs: Record; - setCredentialInputs: (inputs: Record) => void; - onSave: () => void; - saving: boolean; -}) { - return ( -
-

- {configured ? "Update Credentials" : "Credentials"} -

+ function handleSave() { + const credentials: CreateMessagingInstanceRequest["credentials"] = {}; - {platform === "discord" && ( -
- - - setCredentialInputs({ - ...credentialInputs, - discord_token: e.target.value, - }) - } - placeholder={ - configured - ? "Enter new token to update" - : "MTk4NjIyNDgzNDcxOTI1MjQ4.D..." - } - onKeyDown={(e) => { - if (e.key === "Enter") onSave(); - }} - /> -

- Need help?{" "} - - Read the Discord setup docs → - -

+ if (platform === "discord") { + if (!credentialInputs.discord_token?.trim()) { + setMessage({text: "Bot token is required", type: "error"}); + return; + } + credentials.discord_token = credentialInputs.discord_token.trim(); + } else if (platform === "slack") { + if (!credentialInputs.slack_bot_token?.trim() || !credentialInputs.slack_app_token?.trim()) { + setMessage({text: "Both bot token and app token are required", type: "error"}); + return; + } + credentials.slack_bot_token = credentialInputs.slack_bot_token.trim(); + credentials.slack_app_token = credentialInputs.slack_app_token.trim(); + } else if (platform === "telegram") { + if (!credentialInputs.telegram_token?.trim()) { + setMessage({text: "Bot token is required", type: "error"}); + return; + } + credentials.telegram_token = credentialInputs.telegram_token.trim(); + } else if (platform === "twitch") { + if (!credentialInputs.twitch_username?.trim() || !credentialInputs.twitch_oauth_token?.trim()) { + setMessage({text: "Username and OAuth token are required", type: "error"}); + return; + } + credentials.twitch_username = credentialInputs.twitch_username.trim(); + credentials.twitch_oauth_token = credentialInputs.twitch_oauth_token.trim(); + if (credentialInputs.twitch_client_id?.trim()) + credentials.twitch_client_id = credentialInputs.twitch_client_id.trim(); + if (credentialInputs.twitch_client_secret?.trim()) + credentials.twitch_client_secret = credentialInputs.twitch_client_secret.trim(); + if (credentialInputs.twitch_refresh_token?.trim()) + credentials.twitch_refresh_token = credentialInputs.twitch_refresh_token.trim(); + } else if (platform === "email") { + if (!credentialInputs.email_imap_host?.trim() || !credentialInputs.email_smtp_host?.trim()) { + setMessage({text: "IMAP host and SMTP host are required", type: "error"}); + return; + } + if (!credentialInputs.email_imap_username?.trim() || !credentialInputs.email_imap_password?.trim()) { + setMessage({text: "IMAP username and password are required", type: "error"}); + return; + } + if (!credentialInputs.email_smtp_username?.trim() || !credentialInputs.email_smtp_password?.trim()) { + setMessage({text: "SMTP username and password are required", type: "error"}); + return; + } + if (!credentialInputs.email_from_address?.trim()) { + setMessage({text: "From address is required", type: "error"}); + return; + } + credentials.email_imap_host = credentialInputs.email_imap_host.trim(); + credentials.email_imap_username = credentialInputs.email_imap_username.trim(); + credentials.email_imap_password = credentialInputs.email_imap_password.trim(); + credentials.email_smtp_host = credentialInputs.email_smtp_host.trim(); + credentials.email_smtp_username = credentialInputs.email_smtp_username.trim(); + credentials.email_smtp_password = credentialInputs.email_smtp_password.trim(); + credentials.email_from_address = credentialInputs.email_from_address.trim(); + if (credentialInputs.email_imap_port?.trim()) + credentials.email_imap_port = parseInt(credentialInputs.email_imap_port.trim(), 10) || undefined; + if (credentialInputs.email_smtp_port?.trim()) + credentials.email_smtp_port = parseInt(credentialInputs.email_smtp_port.trim(), 10) || undefined; + } else if (platform === "webhook") { + if (credentialInputs.webhook_port?.trim()) + credentials.webhook_port = parseInt(credentialInputs.webhook_port.trim(), 10) || undefined; + if (credentialInputs.webhook_bind?.trim()) + credentials.webhook_bind = credentialInputs.webhook_bind.trim(); + if (credentialInputs.webhook_auth_token?.trim()) + credentials.webhook_auth_token = credentialInputs.webhook_auth_token.trim(); + } + + if (!isDefault && !instanceName.trim()) { + setMessage({text: "Instance name is required", type: "error"}); + return; + } + + createInstance.mutate({ + platform, + name: isDefault ? undefined : instanceName.trim(), + credentials, + }); + } + + const docLink = DOC_LINKS[platform]; + + return ( +
+
+
+ + + {isDefault + ? `Add ${PLATFORM_LABELS[platform]}` + : `Add ${PLATFORM_LABELS[platform]} Instance`} +
- )} - {platform === "slack" && ( - <> + {/* Instance name (only for non-default) */} + {!isDefault && (
-
+ )} + + {/* Platform-specific credential fields */} + {platform === "discord" && (
- + - setCredentialInputs({ - ...credentialInputs, - slack_app_token: e.target.value, - }) - } - placeholder={ - configured ? "Enter new token to update" : "xapp-..." - } - onKeyDown={(e) => { - if (e.key === "Enter") onSave(); - }} + value={credentialInputs.discord_token ?? ""} + onChange={(e) => setCredentialInputs({...credentialInputs, discord_token: e.target.value})} + placeholder="MTk4NjIyNDgzNDcxOTI1MjQ4.D..." + onKeyDown={(e) => { if (e.key === "Enter") handleSave(); }} />
-

- Need help?{" "} - - Read the Slack setup docs → - -

- - )} - - {platform === "telegram" && ( -
- - - setCredentialInputs({ - ...credentialInputs, - telegram_token: e.target.value, - }) - } - placeholder={ - configured - ? "Enter new token to update" - : "123456789:ABCdefGHI..." - } - onKeyDown={(e) => { - if (e.key === "Enter") onSave(); - }} - /> -

- Need help?{" "} - - Read the Telegram setup docs → - -

-
- )} + )} - {platform === "twitch" && ( - <> -
- - - setCredentialInputs({ - ...credentialInputs, - twitch_username: e.target.value, - }) - } - placeholder={ - configured ? "Enter new username to update" : "my_bot" - } - /> -
-
+ {platform === "slack" && ( + <>
- - - setCredentialInputs({ - ...credentialInputs, - twitch_client_id: e.target.value, - }) - } - placeholder={ - configured ? "Enter new client id to update" : "your-app-client-id" - } - /> -
-
- + - setCredentialInputs({ - ...credentialInputs, - twitch_client_secret: e.target.value, - }) - } - placeholder={ - configured ? "Enter new client secret to update" : "your-app-client-secret" - } + value={credentialInputs.slack_bot_token ?? ""} + onChange={(e) => setCredentialInputs({...credentialInputs, slack_bot_token: e.target.value})} + placeholder="xoxb-..." />
-
-
- + - setCredentialInputs({ - ...credentialInputs, - twitch_oauth_token: e.target.value, - }) - } - placeholder={ - configured ? "Enter new token to update" : "abcd1234..." - } - onKeyDown={(e) => { - if (e.key === "Enter") onSave(); - }} - /> -
-
- - - setCredentialInputs({ - ...credentialInputs, - twitch_refresh_token: e.target.value, - }) - } - placeholder={ - configured ? "Enter new refresh token to update" : "refresh-token-from-twitch" - } + value={credentialInputs.slack_app_token ?? ""} + onChange={(e) => setCredentialInputs({...credentialInputs, slack_app_token: e.target.value})} + placeholder="xapp-..." + onKeyDown={(e) => { if (e.key === "Enter") handleSave(); }} />
+ + )} + + {platform === "telegram" && ( +
+ + setCredentialInputs({...credentialInputs, telegram_token: e.target.value})} + placeholder="123456789:ABCdefGHI..." + onKeyDown={(e) => { if (e.key === "Enter") handleSave(); }} + />
-

- Use tokens from your Twitch application with chat:read and chat:write scopes enabled. Tokens are stored in your Spacebot instance and refreshed automatically while running. -

-

- Need help?{" "} - - Read the Twitch setup docs → - -

- - )} + )} - {platform === "email" && ( - <> -
+ {platform === "twitch" && ( + <>
- + - setCredentialInputs({ - ...credentialInputs, - email_imap_host: e.target.value, - }) - } - placeholder="imap.example.com" + value={credentialInputs.twitch_username ?? ""} + onChange={(e) => setCredentialInputs({...credentialInputs, twitch_username: e.target.value})} + placeholder="my_bot" />
-
- - - setCredentialInputs({ - ...credentialInputs, - email_imap_port: e.target.value, - }) - } - placeholder="993" - /> -
-
-
-
- - - setCredentialInputs({ - ...credentialInputs, - email_imap_username: e.target.value, - }) - } - placeholder="inbox@example.com" - /> +
+
+ + setCredentialInputs({...credentialInputs, twitch_client_id: e.target.value})} + placeholder="your-client-id" + /> +
+
+ + setCredentialInputs({...credentialInputs, twitch_client_secret: e.target.value})} + placeholder="your-client-secret" + /> +
-
- - - setCredentialInputs({ - ...credentialInputs, - email_imap_password: e.target.value, - }) - } - placeholder={configured ? "Enter new password to update" : "App password"} - /> +
+
+ + setCredentialInputs({...credentialInputs, twitch_oauth_token: e.target.value})} + placeholder="abcd1234..." + onKeyDown={(e) => { if (e.key === "Enter") handleSave(); }} + /> +
+
+ + setCredentialInputs({...credentialInputs, twitch_refresh_token: e.target.value})} + placeholder="refresh-token" + /> +
-
-
-
- - - setCredentialInputs({ - ...credentialInputs, - email_smtp_host: e.target.value, - }) - } - placeholder="smtp.example.com" - /> + + )} + + {platform === "email" && ( + <> +
+
+ + setCredentialInputs({...credentialInputs, email_imap_host: e.target.value})} + placeholder="imap.gmail.com" + /> +
+
+ + setCredentialInputs({...credentialInputs, email_imap_port: e.target.value})} + placeholder="993" + /> +
-
- - - setCredentialInputs({ - ...credentialInputs, - email_smtp_port: e.target.value, - }) - } - placeholder="587" - /> +
+
+ + setCredentialInputs({...credentialInputs, email_imap_username: e.target.value})} + placeholder="user@example.com" + /> +
+
+ + setCredentialInputs({...credentialInputs, email_imap_password: e.target.value})} + placeholder="App password" + /> +
-
-
-
- - - setCredentialInputs({ - ...credentialInputs, - email_smtp_username: e.target.value, - }) - } - placeholder="inbox@example.com" - /> +
+
+ + setCredentialInputs({...credentialInputs, email_smtp_host: e.target.value})} + placeholder="smtp.gmail.com" + /> +
+
+ + setCredentialInputs({...credentialInputs, email_smtp_port: e.target.value})} + placeholder="587" + /> +
-
- - - setCredentialInputs({ - ...credentialInputs, - email_smtp_password: e.target.value, - }) - } - placeholder={configured ? "Enter new password to update" : "App password"} - onKeyDown={(e) => { - if (e.key === "Enter") onSave(); - }} - /> +
+
+ + setCredentialInputs({...credentialInputs, email_smtp_username: e.target.value})} + placeholder="user@example.com" + /> +
+
+ + setCredentialInputs({...credentialInputs, email_smtp_password: e.target.value})} + placeholder="App password" + /> +
-
-
- + - setCredentialInputs({ - ...credentialInputs, - email_from_address: e.target.value, - }) - } + onChange={(e) => setCredentialInputs({...credentialInputs, email_from_address: e.target.value})} placeholder="bot@example.com" + onKeyDown={(e) => { if (e.key === "Enter") handleSave(); }} />
+ + )} + + {platform === "webhook" && ( + <> +
+
+ + setCredentialInputs({...credentialInputs, webhook_port: e.target.value})} + placeholder="18789" + /> +
+
+ + setCredentialInputs({...credentialInputs, webhook_bind: e.target.value})} + placeholder="127.0.0.1" + /> +
+
- + - setCredentialInputs({ - ...credentialInputs, - email_from_name: e.target.value, - }) - } - placeholder="Spacebot" + value={credentialInputs.webhook_auth_token ?? ""} + onChange={(e) => setCredentialInputs({...credentialInputs, webhook_auth_token: e.target.value})} + placeholder="Optional — leave empty for no auth" + onKeyDown={(e) => { if (e.key === "Enter") handleSave(); }} />
-
+ + )} + + {docLink && (

- Use provider app passwords where possible. If ports are omitted, Spacebot defaults to IMAP 993 and SMTP 587. + Need help?{" "} + + Read the {PLATFORM_LABELS[platform]} setup docs → +

- - )} - - {platform === "webhook" && ( -

- Webhook receiver requires no additional credentials. -

- )} - - {platform !== "webhook" && - Object.values(credentialInputs).some((v) => v?.trim()) && ( - )} -
- ); -} -function BindingsSection({ - platform, - bindings, - agents, - isEditingOrAdding, - editingBinding, - bindingForm, - setBindingForm, - onStartAdd, - onStartEdit, - onCancelEdit, - onAdd, - onUpdate, - onDelete, - addPending, - updatePending, - deletePending, -}: { - platform: Platform; - bindings: BindingInfo[]; - agents: {id: string}[]; - isEditingOrAdding: boolean; - editingBinding: BindingInfo | null; - bindingForm: { - agent_id: string; - guild_id: string; - workspace_id: string; - chat_id: string; - channel_ids: string[]; - require_mention: boolean; - dm_allowed_users: string[]; - }; - setBindingForm: (form: any) => void; - onStartAdd: () => void; - onStartEdit: (binding: BindingInfo) => void; - onCancelEdit: () => void; - onAdd: () => void; - onUpdate: () => void; - onDelete: (binding: BindingInfo) => void; - addPending: boolean; - updatePending: boolean; - deletePending: boolean; -}) { - return ( -
-
-

Bindings

- -
+ {message && ( +
+ {message.text} +
+ )} - {/* Binding list */} - {bindings.length > 0 ? ( -
- {bindings.map((binding, idx) => ( -
-
- {binding.agent_id} -
- {binding.guild_id && Guild: {binding.guild_id}} - {binding.workspace_id && ( - Workspace: {binding.workspace_id} - )} - {binding.chat_id && Chat: {binding.chat_id}} - {binding.channel_ids.length > 0 && ( - - {binding.channel_ids.length} channel - {binding.channel_ids.length > 1 ? "s" : ""} - - )} - {binding.dm_allowed_users.length > 0 && ( - - {binding.dm_allowed_users.length} DM user - {binding.dm_allowed_users.length > 1 ? "s" : ""} - - )} - {binding.require_mention && ( - Mention only - )} - {!binding.guild_id && - !binding.workspace_id && - !binding.chat_id && - binding.channel_ids.length === 0 && ( - All conversations - )} -
-
- - -
- ))} +
+ +
- ) : ( -

- No bindings. Messages will route to the default agent. -

- )} - - {/* Add/Edit binding modal */} - { - if (!open) onCancelEdit(); - }} - > - - - - {editingBinding ? "Edit Binding" : "Add Binding"} - - - - - +
); } +// --- Binding Form (shared between add/edit) --- + function BindingForm({ platform, agents, @@ -1116,6 +979,7 @@ function BindingForm({ workspace_id: string; chat_id: string; channel_ids: string[]; + require_mention: boolean; dm_allowed_users: string[]; }; setBindingForm: (form: any) => void; @@ -1127,21 +991,15 @@ function BindingForm({ return (
- + @@ -1149,62 +1007,46 @@ function BindingForm({ {platform === "discord" && (
- + - setBindingForm({...bindingForm, guild_id: e.target.value}) - } - placeholder="Optional — leave empty for all servers" + onChange={(e) => setBindingForm({...bindingForm, guild_id: e.target.value})} + placeholder="Optional -- leave empty for all servers" />
)} {platform === "slack" && (
- + - setBindingForm({...bindingForm, workspace_id: e.target.value}) - } - placeholder="Optional — leave empty for all workspaces" + onChange={(e) => setBindingForm({...bindingForm, workspace_id: e.target.value})} + placeholder="Optional -- leave empty for all workspaces" />
)} {platform === "telegram" && (
- + - setBindingForm({...bindingForm, chat_id: e.target.value}) - } - placeholder="Optional — leave empty for all chats" + onChange={(e) => setBindingForm({...bindingForm, chat_id: e.target.value})} + placeholder="Optional -- leave empty for all chats" />
)} {(platform === "discord" || platform === "slack") && (
- + - setBindingForm({...bindingForm, channel_ids: ids}) - } + onChange={(ids) => setBindingForm({...bindingForm, channel_ids: ids})} placeholder="Add channel ID..." />
@@ -1215,49 +1057,35 @@ function BindingForm({ - setBindingForm({...bindingForm, require_mention: e.target.checked}) - } + onChange={(e) => setBindingForm({...bindingForm, require_mention: e.target.checked})} className="h-4 w-4 rounded border-app-line bg-app-box" /> - +
)} {platform === "twitch" && (
- + - setBindingForm({...bindingForm, channel_ids: ids}) - } + onChange={(ids) => setBindingForm({...bindingForm, channel_ids: ids})} placeholder="Add channel name..." />
)}
- + - setBindingForm({...bindingForm, dm_allowed_users: users}) - } + onChange={(users) => setBindingForm({...bindingForm, dm_allowed_users: users})} placeholder="Add user ID..." />
- + @@ -1266,54 +1094,29 @@ function BindingForm({ ); } -function DisconnectSection({ +// --- Backward compat: keep DisabledChannelCard export for any other consumers --- + +export function DisabledChannelCard({ + platform, name, - confirmDisconnect, - setConfirmDisconnect, - onDisconnect, - disconnecting, + description, }: { + platform: string; name: string; - confirmDisconnect: boolean; - setConfirmDisconnect: (v: boolean) => void; - onDisconnect: () => void; - disconnecting: boolean; + description: string; }) { return ( -
- {!confirmDisconnect ? ( - - ) : ( -
-

- This will remove all credentials and bindings for {name}. The bot - will stop responding immediately. -

-
- - -
+
+
+ +
+ {name} +

{description}

- )} + +
); } diff --git a/interface/src/components/Sidebar.tsx b/interface/src/components/Sidebar.tsx index ef51575b8..863b9bc48 100644 --- a/interface/src/components/Sidebar.tsx +++ b/interface/src/components/Sidebar.tsx @@ -35,12 +35,13 @@ interface SidebarProps { interface SortableAgentItemProps { agentId: string; + displayName?: string; activity?: { workers: number; branches: number }; isActive: boolean; collapsed: boolean; } -function SortableAgentItem({ agentId, activity, isActive, collapsed }: SortableAgentItemProps) { +function SortableAgentItem({ agentId, displayName, activity, isActive, collapsed }: SortableAgentItemProps) { const { attributes, listeners, @@ -67,9 +68,9 @@ function SortableAgentItem({ agentId, activity, isActive, collapsed }: SortableA isActive ? "bg-sidebar-selected text-sidebar-ink" : "text-sidebar-inkDull hover:bg-sidebar-selected/50" }`} style={{ pointerEvents: isDragging ? 'none' : 'auto' }} - title={agentId} + title={displayName ?? agentId} > - {agentId.charAt(0).toUpperCase()} + {(displayName ?? agentId).charAt(0).toUpperCase()}
); @@ -87,7 +88,7 @@ function SortableAgentItem({ agentId, activity, isActive, collapsed }: SortableA }`} style={{ pointerEvents: isDragging ? 'none' : 'auto' }} > - {agentId} + {displayName ?? agentId} {activity && (activity.workers > 0 || activity.branches > 0) && (
{activity.workers > 0 && ( @@ -126,6 +127,11 @@ export function Sidebar({ liveStates, collapsed, onToggle }: SidebarProps) { const channels = channelsData?.channels ?? []; const agentIds = useMemo(() => agents.map((a) => a.id), [agents]); + const agentDisplayNames = useMemo(() => { + const map: Record = {}; + for (const a of agents) map[a.id] = a.display_name; + return map; + }, [agents]); const [agentOrder, setAgentOrder] = useAgentOrder(agentIds); const matchRoute = useMatchRoute(); @@ -225,17 +231,18 @@ export function Sidebar({ liveStates, collapsed, onToggle }: SidebarProps) { onDragEnd={handleDragEnd} > - {agentOrder.map((agentId) => { - const isActive = !!matchRoute({ to: "/agents/$agentId", params: { agentId }, fuzzy: true }); - return ( - - ); - })} + {agentOrder.map((agentId) => { + const isActive = !!matchRoute({ to: "/agents/$agentId", params: { agentId }, fuzzy: true }); + return ( + + ); + })}