diff --git a/canvas/src/components/tabs/ChannelsTab.tsx b/canvas/src/components/tabs/ChannelsTab.tsx index b7e93ea4..fc5c09de 100644 --- a/canvas/src/components/tabs/ChannelsTab.tsx +++ b/canvas/src/components/tabs/ChannelsTab.tsx @@ -4,9 +4,23 @@ import { useState, useEffect, useCallback, useId } from "react"; import { api } from "@/lib/api"; import { ConfirmDialog } from "@/components/ConfirmDialog"; +// ConfigField mirrors the Go struct returned by GET /channels/adapters — +// the UI renders one input per field in the order the adapter returns +// them, so per-platform form shape stays server-owned. +interface ConfigField { + key: string; + label: string; + type: "text" | "password" | "textarea"; + required: boolean; + sensitive?: boolean; + placeholder?: string; + help?: string; +} + interface ChannelAdapter { type: string; display_name: string; + config_schema?: ConfigField[]; } interface Channel { @@ -25,6 +39,11 @@ interface Props { workspaceId: string; } +// Telegram is the only platform that supports "Detect Chats" via +// getUpdates. Every other platform uses a webhook URL that already +// encodes the chat, so the button is only offered when useful. +const SUPPORTS_DETECT_CHATS = new Set(["telegram"]); + function relativeTime(iso: string | null | undefined): string { if (!iso) return "never"; const diff = Date.now() - new Date(iso).getTime(); @@ -41,11 +60,12 @@ export function ChannelsTab({ workspaceId }: Props) { const [showForm, setShowForm] = useState(false); const [testing, setTesting] = useState(null); const [pendingDelete, setPendingDelete] = useState(null); + const [error, setError] = useState(""); - // Form state + // Form state — schema-driven: formValues holds the typed-in config for + // whichever adapter is currently selected, keyed by ConfigField.key. const [formType, setFormType] = useState("telegram"); - const [formBotToken, setFormBotToken] = useState(""); - const [formChatId, setFormChatId] = useState(""); + const [formValues, setFormValues] = useState>({}); const [formAllowedUsers, setFormAllowedUsers] = useState(""); const [formError, setFormError] = useState(""); const [discovering, setDiscovering] = useState(false); @@ -53,18 +73,13 @@ export function ChannelsTab({ workspaceId }: Props) { const [selectedChats, setSelectedChats] = useState>(new Set()); const [showManualInput, setShowManualInput] = useState(false); - // Stable IDs for label↔input associations (WCAG 1.3.1) const platformId = useId(); - const botTokenId = useId(); - const chatIdId = useId(); const allowedUsersId = useId(); + const currentAdapter = adapters.find((a) => a.type === formType); + const currentSchema: ConfigField[] = currentAdapter?.config_schema || []; + const load = useCallback(async () => { - // Fetch channels and adapters independently so a failure in one - // doesn't blank the other. Previously a single Promise.all + silent - // catch meant ANY request failing left both `channels` and - // `adapters` empty — the user saw a "+ Connect" button with no - // platform options, with no clue why. const [chResult, adResult] = await Promise.allSettled([ api.get(`/workspaces/${workspaceId}/channels`), api.get(`/channels/adapters`), @@ -82,8 +97,6 @@ export function ChannelsTab({ workspaceId }: Props) { console.warn("ChannelsTab: adapters load failed", adResult.reason); errors.push("platforms"); } - // Surface BOTH failure modes so the user can distinguish - // "no channels configured" from "API unreachable". if (errors.length > 0) { setError(`Failed to load ${errors.join(" and ")} — try refreshing`); } else { @@ -100,8 +113,24 @@ export function ChannelsTab({ workspaceId }: Props) { return () => clearInterval(interval); }, [load]); + // Reset form values when the selected platform changes — each platform + // has a different field set, so reusing old values would leak stale + // data across platforms. + useEffect(() => { + setFormValues({}); + setDiscoveredChats([]); + setSelectedChats(new Set()); + setShowManualInput(false); + setFormError(""); + }, [formType]); + + const setFieldValue = (key: string, value: string) => { + setFormValues((prev) => ({ ...prev, [key]: value })); + }; + const handleDiscover = async () => { - if (!formBotToken) { + const botToken = formValues["bot_token"] || ""; + if (!botToken) { setFormError("Enter a bot token first"); return; } @@ -111,16 +140,15 @@ export function ChannelsTab({ workspaceId }: Props) { try { const res = await api.post<{ chats: { chat_id: string; name: string; type: string }[]; hint: string }>( `/channels/discover`, - { channel_type: formType, bot_token: formBotToken, workspace_id: workspaceId } + { channel_type: formType, bot_token: botToken, workspace_id: workspaceId } ); const chats = res.chats || []; setDiscoveredChats(chats); if (chats.length === 0) { setFormError("No chats found. For groups: add the bot and send a message. For DMs: send /start to the bot first. Then retry."); } else { - // Auto-select all discovered chats setSelectedChats(new Set(chats.map((c) => c.chat_id))); - setFormChatId(chats.map((c) => c.chat_id).join(", ")); + setFieldValue("chat_id", chats.map((c) => c.chat_id).join(", ")); } } catch (e) { setFormError(String(e)); @@ -134,15 +162,22 @@ export function ChannelsTab({ workspaceId }: Props) { const next = new Set(prev); if (next.has(chatId)) next.delete(chatId); else next.add(chatId); - setFormChatId(Array.from(next).join(", ")); + setFieldValue("chat_id", Array.from(next).join(", ")); return next; }); }; const handleCreate = async () => { setFormError(""); - if (!formBotToken || !formChatId) { - setFormError("Bot token and chat ID are required"); + // Client-side required-field check so the user sees the gap before + // we round-trip to the server. ValidateConfig on the backend remains + // authoritative — adapter-specific rules like "bot_token OR webhook_url" + // for Slack aren't expressible in required-flag alone. + const missing = currentSchema + .filter((f) => f.required && !(formValues[f.key] || "").trim()) + .map((f) => f.label); + if (missing.length > 0) { + setFormError(`Required: ${missing.join(", ")}`); return; } try { @@ -150,14 +185,20 @@ export function ChannelsTab({ workspaceId }: Props) { .split(",") .map((s) => s.trim()) .filter(Boolean); + // Only send keys the schema knows about — avoids accidentally + // persisting stale values when the user switched platforms mid-edit. + const config: Record = {}; + for (const f of currentSchema) { + const v = (formValues[f.key] || "").trim(); + if (v) config[f.key] = v; + } await api.post(`/workspaces/${workspaceId}/channels`, { channel_type: formType, - config: { bot_token: formBotToken, chat_id: formChatId }, + config, allowed_users: allowed, }); setShowForm(false); - setFormBotToken(""); - setFormChatId(""); + setFormValues({}); setFormAllowedUsers(""); load(); } catch (e) { @@ -165,8 +206,6 @@ export function ChannelsTab({ workspaceId }: Props) { } }; - const [error, setError] = useState(""); - const handleToggle = async (ch: Channel) => { try { await api.patch(`/workspaces/${workspaceId}/channels/${ch.id}`, { @@ -228,7 +267,7 @@ export function ChannelsTab({ workspaceId }: Props) { )} - {/* Create form */} + {/* Create form — schema-driven */} {showForm && (
@@ -244,73 +283,69 @@ export function ChannelsTab({ workspaceId }: Props) { ))}
-
- - setFormBotToken(e.target.value)} - placeholder="123456:ABC-DEF..." - className="w-full text-xs bg-zinc-900 border border-zinc-700 rounded px-2 py-1.5 text-zinc-300 placeholder-zinc-600" - /> -
-
-
- - + + {/* Render one input per schema field. Fallback path: if the + backend didn't return a schema (older platform version) show + a single bot_token + chat_id pair to preserve the old UX. */} + {currentSchema.length === 0 ? ( +
+ Platform exposes no config schema — upgrade the platform to pick up first-class support.
- {discoveredChats.length > 0 && ( -
- {discoveredChats.map((chat) => ( - - ))} -
- )} - {(discoveredChats.length === 0 || showManualInput) && ( - setFormChatId(e.target.value)} - placeholder="-100123456789, -100987654321" - className="w-full text-xs bg-zinc-900 border border-zinc-700 rounded px-2 py-1.5 text-zinc-300 placeholder-zinc-600" + ) : ( + currentSchema.map((field) => ( + setFieldValue(field.key, v)} + // Detect Chats button lives next to the chat_id input on + // Telegram only (the only platform with getUpdates). + renderExtras={ + field.key === "chat_id" && SUPPORTS_DETECT_CHATS.has(formType) + ? () => ( + <> +
+ +
+ {discoveredChats.length > 0 && ( +
+ {discoveredChats.map((chat) => ( + + ))} + +
+ )} + + ) + : undefined + } /> - )} -

- {discoveredChats.length > 0 ? ( - <> - Chats: {formChatId || "(none selected)"} - {" · "} - - - ) : ( - "Click Detect Chats after adding the bot to groups or sending /start in DMs." - )} -

-
+ )) + )} +
{formError && ( @@ -343,7 +378,7 @@ export function ChannelsTab({ workspaceId }: Props) {

No channels connected

- Connect Telegram, Slack, or Discord to chat with this agent from social platforms. + Connect Telegram, Slack, Discord, or Lark / Feishu to chat with this agent from social platforms.

)} @@ -364,7 +399,7 @@ export function ChannelsTab({ workspaceId }: Props) { {ch.channel_type.charAt(0).toUpperCase() + ch.channel_type.slice(1)} - {ch.config.chat_id} + {ch.config.chat_id || ch.config.channel_id || ""}
@@ -415,3 +450,53 @@ export function ChannelsTab({ workspaceId }: Props) {
); } + +// SchemaField renders one ConfigField as a label + input. Kept inline in +// this file so the ChannelsTab stays self-contained; promote to its own +// module if another tab ever needs it. +function SchemaField({ + field, + value, + onChange, + renderExtras, +}: { + field: ConfigField; + value: string; + onChange: (v: string) => void; + renderExtras?: () => React.ReactNode; +}) { + const inputId = useId(); + const common = + "w-full text-xs bg-zinc-900 border border-zinc-700 rounded px-2 py-1.5 text-zinc-300 placeholder-zinc-600"; + return ( +
+ + {field.type === "textarea" ? ( +