From bc9ebaa2d080259f129e457d46d344027027bfc2 Mon Sep 17 00:00:00 2001 From: viettranx Date: Sat, 14 Mar 2026 15:09:17 +0700 Subject: [PATCH] refactor(channels): unify tab layout widths and schema-driven group overrides - Remove redundant max-w-* constraints from General, Credentials, Groups, and Managers tabs so all tabs fill the parent container equally - Refactor TelegramGroupFields to use ChannelFields with groupOverrideSchema instead of manually rendered form fields - Add tristate, textarea, tool-select, and skill-select field types to ChannelFields renderer - Create SkillNameSelect multi-select component (mirrors ToolNameSelect) - Wire listManagerGroups into Groups tab for quick-add known groups - Add i18n keys for knownGroups and selectOrTypeSkills (en/vi/zh) --- .../components/shared/skill-name-select.tsx | 157 +++++++++++++++++ ui/web/src/i18n/locales/en/channels.json | 1 + ui/web/src/i18n/locales/en/common.json | 1 + ui/web/src/i18n/locales/vi/channels.json | 1 + ui/web/src/i18n/locales/vi/common.json | 1 + ui/web/src/i18n/locales/zh/channels.json | 1 + ui/web/src/i18n/locales/zh/common.json | 1 + .../channel-credentials-tab.tsx | 4 +- .../channel-detail/channel-detail-page.tsx | 1 + .../channel-detail/channel-general-tab.tsx | 2 +- .../channel-detail/channel-groups-tab.tsx | 20 ++- .../channel-detail/channel-managers-tab.tsx | 2 +- ui/web/src/pages/channels/channel-fields.tsx | 115 +++++++++++- ui/web/src/pages/channels/channel-schemas.ts | 17 +- .../pages/channels/telegram-group-fields.tsx | 164 +----------------- .../channels/telegram-group-overrides.tsx | 60 ++++++- 16 files changed, 374 insertions(+), 174 deletions(-) create mode 100644 ui/web/src/components/shared/skill-name-select.tsx diff --git a/ui/web/src/components/shared/skill-name-select.tsx b/ui/web/src/components/shared/skill-name-select.tsx new file mode 100644 index 00000000..71cb82f6 --- /dev/null +++ b/ui/web/src/components/shared/skill-name-select.tsx @@ -0,0 +1,157 @@ +import { useMemo, useState, useRef, useEffect, useLayoutEffect } from "react"; +import { createPortal } from "react-dom"; +import { useTranslation } from "react-i18next"; +import { X, ChevronDownIcon } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { useSkills } from "@/pages/skills/hooks/use-skills"; + +interface SkillNameSelectProps { + value: string[]; + onChange: (value: string[]) => void; + placeholder?: string; + className?: string; +} + +export function SkillNameSelect({ + value, + onChange, + placeholder, + className, +}: SkillNameSelectProps) { + const { t } = useTranslation("common"); + const { skills } = useSkills(); + const [open, setOpen] = useState(false); + const [search, setSearch] = useState(""); + const containerRef = useRef(null); + const inputRef = useRef(null); + const dropdownRef = useRef(null); + const [dropdownStyle, setDropdownStyle] = useState({}); + + const allSkills = useMemo(() => { + return skills.map((s) => ({ name: s.name, description: s.description })); + }, [skills]); + + const filtered = useMemo(() => { + const q = search.toLowerCase(); + return allSkills + .filter((s) => !value.includes(s.name)) + .filter((s) => !q || s.name.toLowerCase().includes(q) || s.description.toLowerCase().includes(q)); + }, [allSkills, value, search]); + + useLayoutEffect(() => { + if (!open || !containerRef.current) return; + const rect = containerRef.current.getBoundingClientRect(); + setDropdownStyle({ + position: "fixed", + top: rect.bottom + 4, + left: rect.left, + width: rect.width, + zIndex: 9999, + }); + }, [open, search]); + + useEffect(() => { + if (!open) return; + const handler = (e: MouseEvent) => { + const target = e.target as Node; + if ( + containerRef.current && !containerRef.current.contains(target) && + (!dropdownRef.current || !dropdownRef.current.contains(target)) + ) { + setOpen(false); + } + }; + document.addEventListener("mousedown", handler); + return () => document.removeEventListener("mousedown", handler); + }, [open]); + + const addSkill = (name: string) => { + if (!value.includes(name)) { + onChange([...value, name]); + } + setSearch(""); + inputRef.current?.focus(); + }; + + const removeSkill = (name: string) => { + onChange(value.filter((v) => v !== name)); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" || e.key === ",") { + e.preventDefault(); + const trimmed = search.trim().replace(/,$/, ""); + if (trimmed) addSkill(trimmed); + } + if (e.key === "Backspace" && !search && value.length > 0) { + removeSkill(value[value.length - 1]!); + } + }; + + return ( +
+
inputRef.current?.focus()} + > + {value.map((name) => ( + + {name} + + + ))} + { + setSearch(e.target.value); + if (!open) setOpen(true); + }} + onFocus={() => setOpen(true)} + onKeyDown={handleKeyDown} + placeholder={value.length === 0 ? (placeholder ?? t("selectOrTypeSkills")) : ""} + className="placeholder:text-muted-foreground min-w-[80px] flex-1 bg-transparent py-0.5 text-sm outline-none" + /> + setOpen(!open)} + /> +
+ {open && filtered.length > 0 && createPortal( +
+ {filtered.map((s) => ( + + ))} +
, + document.body, + )} +
+ ); +} diff --git a/ui/web/src/i18n/locales/en/channels.json b/ui/web/src/i18n/locales/en/channels.json index c6d2c4a3..6e07a1c8 100644 --- a/ui/web/src/i18n/locales/en/channels.json +++ b/ui/web/src/i18n/locales/en/channels.json @@ -240,6 +240,7 @@ "groupWildcard": "* (wildcard)", "addGroupPlaceholder": "Chat ID (e.g. -100123456) or *", "addGroup": "Add Group", + "knownGroups": "Known groups", "topicOverrides": "Topic Overrides", "topicLabel": "Topic: {{id}}", "addTopicPlaceholder": "Topic thread ID", diff --git a/ui/web/src/i18n/locales/en/common.json b/ui/web/src/i18n/locales/en/common.json index bbfb30e6..7c019911 100644 --- a/ui/web/src/i18n/locales/en/common.json +++ b/ui/web/src/i18n/locales/en/common.json @@ -102,5 +102,6 @@ "builtinTools": "Built-in", "customTools": "Custom", "selectOrTypeTools": "Select or type tool names...", + "selectOrTypeSkills": "Select or type skill names...", "typeAndPressEnter": "Type and press Enter..." } diff --git a/ui/web/src/i18n/locales/vi/channels.json b/ui/web/src/i18n/locales/vi/channels.json index c3b9a80e..e6a368ae 100644 --- a/ui/web/src/i18n/locales/vi/channels.json +++ b/ui/web/src/i18n/locales/vi/channels.json @@ -240,6 +240,7 @@ "groupWildcard": "* (ký tự đại diện)", "addGroupPlaceholder": "ID cuộc trò chuyện (vd: -100123456) hoặc *", "addGroup": "Thêm nhóm", + "knownGroups": "Nhóm đã biết", "topicOverrides": "Ghi đè chủ đề", "topicLabel": "Chủ đề: {{id}}", "addTopicPlaceholder": "ID luồng chủ đề", diff --git a/ui/web/src/i18n/locales/vi/common.json b/ui/web/src/i18n/locales/vi/common.json index c25085f7..780b1fde 100644 --- a/ui/web/src/i18n/locales/vi/common.json +++ b/ui/web/src/i18n/locales/vi/common.json @@ -73,6 +73,7 @@ "selectAgent": "Chọn agent", "selectFileToView": "Chọn một tệp để xem nội dung", "selectOrTypeTools": "Chọn hoặc nhập tên công cụ...", + "selectOrTypeSkills": "Chọn hoặc nhập tên kỹ năng...", "selectProvider": "Chọn provider", "sendMessage": "Gửi tin nhắn... (Shift+Enter để xuống dòng)", "sendMessageTitle": "Gửi tin nhắn", diff --git a/ui/web/src/i18n/locales/zh/channels.json b/ui/web/src/i18n/locales/zh/channels.json index 8fa53728..0dcae9b5 100644 --- a/ui/web/src/i18n/locales/zh/channels.json +++ b/ui/web/src/i18n/locales/zh/channels.json @@ -240,6 +240,7 @@ "groupWildcard": "* (通配符)", "addGroupPlaceholder": "聊天 ID(例如 -100123456)或 *", "addGroup": "添加群组", + "knownGroups": "已知群组", "topicOverrides": "话题覆盖", "topicLabel": "话题: {{id}}", "addTopicPlaceholder": "话题线程 ID", diff --git a/ui/web/src/i18n/locales/zh/common.json b/ui/web/src/i18n/locales/zh/common.json index cef1499a..3986573c 100644 --- a/ui/web/src/i18n/locales/zh/common.json +++ b/ui/web/src/i18n/locales/zh/common.json @@ -73,6 +73,7 @@ "selectAgent": "选择Agent", "selectFileToView": "选择文件以查看其内容", "selectOrTypeTools": "选择或输入工具名称...", + "selectOrTypeSkills": "选择或输入技能名称...", "selectProvider": "选择Provider", "sendMessage": "发送消息... (Shift+Enter 换行)", "sendMessageTitle": "发送消息", diff --git a/ui/web/src/pages/channels/channel-detail/channel-credentials-tab.tsx b/ui/web/src/pages/channels/channel-detail/channel-credentials-tab.tsx index 1263572b..77307143 100644 --- a/ui/web/src/pages/channels/channel-detail/channel-credentials-tab.tsx +++ b/ui/web/src/pages/channels/channel-detail/channel-credentials-tab.tsx @@ -49,7 +49,7 @@ export function ChannelCredentialsTab({ instance, onUpdate }: ChannelCredentials if (fields.length === 0) { return ( -
+

{t("detail.credentials.noSchema")}

@@ -58,7 +58,7 @@ export function ChannelCredentialsTab({ instance, onUpdate }: ChannelCredentials } return ( -
+

{t("detail.credentials.hint")}

diff --git a/ui/web/src/pages/channels/channel-detail/channel-detail-page.tsx b/ui/web/src/pages/channels/channel-detail/channel-detail-page.tsx index 922c1084..1e81f9ab 100644 --- a/ui/web/src/pages/channels/channel-detail/channel-detail-page.tsx +++ b/ui/web/src/pages/channels/channel-detail/channel-detail-page.tsx @@ -126,6 +126,7 @@ export function ChannelDetailPage({ instanceId, onBack }: ChannelDetailPageProps )} diff --git a/ui/web/src/pages/channels/channel-detail/channel-general-tab.tsx b/ui/web/src/pages/channels/channel-detail/channel-general-tab.tsx index 026c878f..975e639a 100644 --- a/ui/web/src/pages/channels/channel-detail/channel-general-tab.tsx +++ b/ui/web/src/pages/channels/channel-detail/channel-general-tab.tsx @@ -52,7 +52,7 @@ export function ChannelGeneralTab({ instance, agents, onUpdate }: ChannelGeneral }; return ( -
+
{/* Read-only fields */}
diff --git a/ui/web/src/pages/channels/channel-detail/channel-groups-tab.tsx b/ui/web/src/pages/channels/channel-detail/channel-groups-tab.tsx index edec255d..65cc2154 100644 --- a/ui/web/src/pages/channels/channel-detail/channel-groups-tab.tsx +++ b/ui/web/src/pages/channels/channel-detail/channel-groups-tab.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useState, useEffect, useCallback } from "react"; import { useTranslation } from "react-i18next"; import { Save, Check, AlertCircle } from "lucide-react"; import { Button } from "@/components/ui/button"; @@ -6,6 +6,7 @@ import type { ChannelInstanceData } from "@/types/channel"; import { TelegramGroupOverrides } from "../telegram-group-overrides"; import type { TelegramGroupConfigValues } from "../telegram-group-fields"; import type { TelegramTopicConfigValues } from "../telegram-topic-overrides"; +import type { GroupManagerGroupInfo } from "../hooks/use-channel-detail"; interface GroupConfigWithTopics extends TelegramGroupConfigValues { topics?: Record; @@ -14,9 +15,10 @@ interface GroupConfigWithTopics extends TelegramGroupConfigValues { interface ChannelGroupsTabProps { instance: ChannelInstanceData; onUpdate: (updates: Record) => Promise; + listManagerGroups: () => Promise; } -export function ChannelGroupsTab({ instance, onUpdate }: ChannelGroupsTabProps) { +export function ChannelGroupsTab({ instance, onUpdate, listManagerGroups }: ChannelGroupsTabProps) { const { t } = useTranslation("channels"); const config = (instance.config ?? {}) as Record; const [groups, setGroups] = useState>( @@ -25,6 +27,16 @@ export function ChannelGroupsTab({ instance, onUpdate }: ChannelGroupsTabProps) const [saving, setSaving] = useState(false); const [saveError, setSaveError] = useState(null); const [saved, setSaved] = useState(false); + const [knownGroups, setKnownGroups] = useState([]); + + const loadKnownGroups = useCallback(async () => { + try { + const g = await listManagerGroups(); + setKnownGroups(g); + } catch { /* handled by http hook */ } + }, [listManagerGroups]); + + useEffect(() => { loadKnownGroups(); }, [loadKnownGroups]); const handleSave = async () => { const hasGroups = Object.keys(groups).length > 0; @@ -49,8 +61,8 @@ export function ChannelGroupsTab({ instance, onUpdate }: ChannelGroupsTabProps) }; return ( -
- setGroups(g)} /> +
+ setGroups(g)} knownGroups={knownGroups} /> {saveError && (
diff --git a/ui/web/src/pages/channels/channel-detail/channel-managers-tab.tsx b/ui/web/src/pages/channels/channel-detail/channel-managers-tab.tsx index be8036ca..b32755d5 100644 --- a/ui/web/src/pages/channels/channel-detail/channel-managers-tab.tsx +++ b/ui/web/src/pages/channels/channel-detail/channel-managers-tab.tsx @@ -262,7 +262,7 @@ export function ChannelManagersTab({ }; return ( -
+

{t("detail.managers.description")}

diff --git a/ui/web/src/pages/channels/channel-fields.tsx b/ui/web/src/pages/channels/channel-fields.tsx index bbfe9759..abb9950f 100644 --- a/ui/web/src/pages/channels/channel-fields.tsx +++ b/ui/web/src/pages/channels/channel-fields.tsx @@ -10,8 +10,12 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { ToolNameSelect } from "@/components/shared/tool-name-select"; +import { SkillNameSelect } from "@/components/shared/skill-name-select"; import type { FieldDef } from "./channel-schemas"; +const INHERIT = "__inherit__"; + interface ChannelFieldsProps { fields: FieldDef[]; values: Record; @@ -127,6 +131,115 @@ function FieldRenderer({
); + case "tristate": { + // Tri-state: undefined = inherit, value = override. + // With options: select with Inherit + custom options (string value). + // Without options: select with Inherit/Yes/No (boolean value). + const inheritLabel = t("groupOverrides.fields.inherit", { defaultValue: "Inherit" }); + + if (field.options) { + // String tri-state (e.g. group_policy) + const allOptions = [{ value: INHERIT, label: inheritLabel }, ...field.options]; + const selectValue = (value as string) || INHERIT; + return ( +
+ + + {help &&

{help}

} +
+ ); + } + + // Boolean tri-state (e.g. require_mention, enabled) + const yesLabel = t("groupOverrides.fields.yes", { defaultValue: "Yes" }); + const noLabel = t("groupOverrides.fields.no", { defaultValue: "No" }); + const triOptions = [ + { value: INHERIT, label: inheritLabel }, + { value: "true", label: yesLabel }, + { value: "false", label: noLabel }, + ]; + const boolToStr = (v: unknown): string => { + if (v === undefined || v === null) return INHERIT; + return v ? "true" : "false"; + }; + const strToBool = (v: string): boolean | undefined => { + if (v === INHERIT) return undefined; + return v === "true"; + }; + + return ( +
+ + + {help &&

{help}

} +
+ ); + } + + case "textarea": + return ( +
+ +