refactor(ui): simplify agent detail page — remove Links, Shares tabs and Other Config section

Remove unused UI sections from agent detail:
- Links tab + link-sections components + use-agent-links hook
- Shares tab + use-agent-shares hook
- "Quality & Advanced" config group + OtherConfigSection

Also fix skills tab sort (ungranted non-system first) and
harden use-contact-search with Array.isArray guard.
This commit is contained in:
viettranx committed 2026-03-17 22:28:00 +07:00
1 parent 843b550651
commit 57754a569b
15 files changed
+14 -1025

No files matched your search

@@ -19,7 +19,6 @@ import {
ContextPruningSection,
SandboxSection,
MemorySection,
OtherConfigSection,
ThinkingSection,
WorkspaceSharingSection,
} from "./config-sections";
@@ -52,27 +51,11 @@ export function AgentConfigTab({ agent, onUpdate }: AgentConfigTabProps) {
const otherObj = (agent.other_config ?? {}) as Record<string, unknown>;
const initialThinkingLevel = (typeof otherObj.thinking_level === "string" ? otherObj.thinking_level : "off");
const initialWsSharing = (otherObj.workspace_sharing ?? {}) as WorkspaceSharingConfig;
// Strip all managed keys so they don't leak into the raw JSON editor.
// General tab manages: emoji, self_evolve, description, skill_evolve, skill_nudge_interval
// Config tab manages: workspace_sharing, thinking_level
const {
workspace_sharing: _ws, thinking_level: _tl,
emoji: _emoji, self_evolve: _se, description: _desc,
skill_evolve: _ske, skill_nudge_interval: _sni,
...otherWithoutManaged
} = otherObj;
const [wsSharing, setWsSharing] = useState<WorkspaceSharingConfig>(initialWsSharing);
const [thinkingLevel, setThinkingLevel] = useState(initialThinkingLevel);
const [otherEnabled, setOtherEnabled] = useState(
agent.other_config != null && Object.keys(otherWithoutManaged).length > 0,
);
const [otherJson, setOtherJson] = useState(
Object.keys(otherWithoutManaged).length > 0 ? JSON.stringify(otherWithoutManaged, null, 2) : "{}",
);
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [saved, setSaved] = useState(false);
@@ -94,13 +77,10 @@ export function AgentConfigTab({ agent, onUpdate }: AgentConfigTabProps) {
};
// Preserve existing other_config fields not managed by this tab (e.g. emoji from General tab).
const existing = (agent.other_config as Record<string, unknown> | null) ?? {};
let otherBase: Record<string, unknown> = { ...existing };
const otherBase: Record<string, unknown> = { ...existing };
// Strip fields managed by this tab — they'll be re-added below from local state.
delete otherBase.thinking_level;
delete otherBase.workspace_sharing;
if (otherEnabled) {
try { Object.assign(otherBase, JSON.parse(otherJson)); } catch { /* keep existing */ }
}
if (thinkingLevel && thinkingLevel !== "off") {
otherBase.thinking_level = thinkingLevel;
}
@@ -182,19 +162,6 @@ export function AgentConfigTab({ agent, onUpdate }: AgentConfigTabProps) {
/>
</div>
{/* Advanced */}
<ConfigGroupHeader
title={t("configGroups.advanced")}
description={t("configGroups.advancedDesc")}
/>
<div className="space-y-4">
<OtherConfigSection
enabled={otherEnabled}
value={otherJson}
onToggle={(v: boolean) => { setOtherEnabled(v); if (!v) setOtherJson("{}"); }}
onChange={setOtherJson}
/>
</div>
<StickySaveBar
onSave={handleSave}
@@ -10,8 +10,6 @@ import { useAgents } from "../hooks/use-agents";
import { AgentGeneralTab } from "./agent-general-tab";
import { AgentConfigTab } from "./agent-config-tab";
import { AgentFilesTab } from "./agent-files-tab";
import { AgentSharesTab } from "./agent-shares-tab";
import { AgentLinksTab } from "./agent-links-tab";
import { AgentSkillsTab } from "./agent-skills-tab";
import { AgentInstancesTab } from "./agent-instances-tab";
import { SummoningModal } from "../summoning-modal";
@@ -155,8 +153,6 @@ export function AgentDetailPage({ agentId, onBack }: AgentDetailPageProps) {
<TabsTrigger value="general">{t("detail.tabs.general")}</TabsTrigger>
<TabsTrigger value="config">{t("detail.tabs.config")}</TabsTrigger>
<TabsTrigger value="files">{t("detail.tabs.files")}</TabsTrigger>
<TabsTrigger value="shares">{t("detail.tabs.shares")}</TabsTrigger>
<TabsTrigger value="links">{t("detail.tabs.links")}</TabsTrigger>
<TabsTrigger value="skills">{t("detail.tabs.skills")}</TabsTrigger>
{agent.agent_type === "predefined" && (
<TabsTrigger value="instances">{t("detail.tabs.instances")}</TabsTrigger>
@@ -182,14 +178,6 @@ export function AgentDetailPage({ agentId, onBack }: AgentDetailPageProps) {
/>
</TabsContent>
<TabsContent value="shares" className="mt-4">
<AgentSharesTab agentId={agentId} />
</TabsContent>
<TabsContent value="links" className="mt-4">
<AgentLinksTab agentId={agentId} />
</TabsContent>
<TabsContent value="skills" className="mt-4">
<AgentSkillsTab agentId={agentId} />
</TabsContent>
@@ -1,100 +0,0 @@
import { useState, useEffect, useMemo } from "react";
import { Link } from "react-router";
import { TriangleAlert } from "lucide-react";
import { useTranslation } from "react-i18next";
import { ConfirmDialog } from "@/components/shared/confirm-dialog";
import { useAgentLinks } from "../hooks/use-agent-links";
import { useAgents } from "../hooks/use-agents";
import type { AgentLinkData } from "@/types/agent";
import { LinkCreateForm, LinkList, LinkEditDialog, linkTargetName } from "./link-sections";
import { ROUTES } from "@/lib/constants";
interface AgentLinksTabProps {
agentId: string;
}
export function AgentLinksTab({ agentId }: AgentLinksTabProps) {
const { t } = useTranslation("agents");
const { links, loading, load, createLink, updateLink, deleteLink } =
useAgentLinks(agentId);
const { agents } = useAgents();
const [editLink, setEditLink] = useState<AgentLinkData | null>(null);
const [deleteTarget, setDeleteTarget] = useState<{
id: string;
name: string;
} | null>(null);
useEffect(() => {
load();
}, [load]);
const agentOptions = useMemo(
() =>
agents
.filter((a) => a.id !== agentId && a.agent_type === "predefined")
.map((a) => ({
value: a.id,
label: a.display_name || a.agent_key,
})),
[agents, agentId],
);
const handleStatusToggle = async (link: AgentLinkData) => {
const newStatus = link.status === "active" ? "disabled" : "active";
await updateLink(link.id, { status: newStatus });
};
return (
<div className="max-w-4xl space-y-6">
<div className="flex items-start gap-3 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 dark:border-amber-900 dark:bg-amber-950/30">
<TriangleAlert className="mt-0.5 h-4 w-4 shrink-0 text-amber-600 dark:text-amber-400" />
<p className="text-sm text-amber-800 dark:text-amber-300">
{t("links.teamsHint").split("Agent Teams")[0]}
<Link to={ROUTES.TEAMS} className="font-medium underline underline-offset-2 hover:text-amber-900 dark:hover:text-amber-200">
Agent Teams
</Link>
{t("links.teamsHint").split("Agent Teams")[1]}
</p>
</div>
<LinkCreateForm agentOptions={agentOptions} onSubmit={createLink} />
<LinkList
links={links}
loading={loading}
agentId={agentId}
onStatusToggle={handleStatusToggle}
onEdit={setEditLink}
onDelete={(link) =>
setDeleteTarget({ id: link.id, name: linkTargetName(link, agentId) })
}
/>
<LinkEditDialog
link={editLink}
onClose={() => setEditLink(null)}
onSave={updateLink}
/>
<ConfirmDialog
open={!!deleteTarget}
onOpenChange={() => setDeleteTarget(null)}
title={t("links.deleteTitle")}
description={t("links.deleteDesc", { name: deleteTarget?.name })}
confirmLabel={t("delete.confirmLabel")}
variant="destructive"
onConfirm={async () => {
if (deleteTarget) {
try {
await deleteLink(deleteTarget.id);
} catch {
// ignore
}
setDeleteTarget(null);
}
}}
/>
</div>
);
}
@@ -1,198 +0,0 @@
import { useState, useCallback, useMemo } from "react";
import { Plus, Trash2, Users } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { Combobox } from "@/components/ui/combobox";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { ConfirmDialog } from "@/components/shared/confirm-dialog";
import { useHttp } from "@/hooks/use-ws";
import { useContactPicker } from "@/hooks/use-contact-picker";
import { useContactResolver } from "@/hooks/use-contact-resolver";
import { useAgentShares } from "../hooks/use-agent-shares";
import type { ChannelContact } from "@/types/contact";
interface AgentSharesTabProps {
agentId: string;
}
const ROLE_OPTIONS = [
{ value: "user" },
{ value: "viewer" },
] as const;
function roleBadgeVariant(role: string) {
switch (role) {
case "owner": return "success" as const;
case "user": return "info" as const;
default: return "outline" as const;
}
}
export function AgentSharesTab({ agentId }: AgentSharesTabProps) {
const { t } = useTranslation("agents");
const http = useHttp();
const { shares, loading, addShare, revokeShare } = useAgentShares(agentId);
const [newUserId, setNewUserId] = useState("");
const [newRole, setNewRole] = useState("user");
const [revokeTarget, setRevokeTarget] = useState<string | null>(null);
// Contact picker for the add form
const listContacts = useCallback(
async (search: string): Promise<ChannelContact[]> => {
const res = await http.get<{ contacts: ChannelContact[] }>("/v1/contacts", {
search,
limit: "20",
});
return res.contacts ?? [];
},
[http],
);
const { options, searchContacts } = useContactPicker(listContacts);
// Resolve display names for existing shares
const shareUserIDs = useMemo(() => shares.map((s) => s.user_id), [shares]);
const { resolve } = useContactResolver(shareUserIDs);
const handleUserIdChange = (val: string) => {
setNewUserId(val);
searchContacts(val);
};
const handleAddShare = async () => {
if (!newUserId.trim()) return;
try {
await addShare(newUserId.trim(), newRole);
setNewUserId("");
setNewRole("user");
} catch {
// ignore
}
};
return (
<div className="max-w-2xl space-y-6">
{/* Add share form */}
<div className="rounded-lg border p-4">
<h3 className="mb-3 text-sm font-medium">{t("shares.grantAccess")}</h3>
<div className="flex flex-col gap-3 sm:flex-row sm:items-end">
<div className="flex-1 space-y-1.5">
<Label htmlFor="shareUserId">{t("shares.userId")}</Label>
<Combobox
value={newUserId}
onChange={handleUserIdChange}
options={options}
placeholder={t("shares.userIdPlaceholder")}
/>
</div>
<div className="w-full space-y-1.5 sm:w-36">
<Label>{t("shares.role")}</Label>
<Select value={newRole} onValueChange={setNewRole}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{ROLE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{t(`shares.role.${opt.value}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button onClick={handleAddShare} disabled={!newUserId.trim()} className="gap-1.5">
<Plus className="h-4 w-4" />
{t("shares.share")}
</Button>
</div>
</div>
{/* Share list */}
{loading && shares.length === 0 ? (
<div className="py-8 text-center text-sm text-muted-foreground">{t("shares.loadingShares")}</div>
) : shares.length === 0 ? (
<div className="flex flex-col items-center gap-2 py-8 text-center">
<Users className="h-8 w-8 text-muted-foreground/50" />
<p className="text-sm text-muted-foreground">{t("shares.noShares")}</p>
<p className="text-xs text-muted-foreground">
{t("shares.noSharesDesc")}
</p>
</div>
) : (
<div className="overflow-x-auto rounded-lg border">
<div className="grid min-w-[300px] grid-cols-[1fr_100px_48px] items-center gap-2 border-b bg-muted/50 px-4 py-2.5 text-xs font-medium text-muted-foreground">
<span>{t("shares.user")}</span>
<span>{t("shares.role")}</span>
<span />
</div>
{shares.map((share) => {
const contact = resolve(share.user_id);
const contactByGrant = share.granted_by ? resolve(share.granted_by) : null;
return (
<div
key={share.user_id}
className="grid min-w-[300px] grid-cols-[1fr_100px_48px] items-center gap-2 border-b px-4 py-3 last:border-0"
>
<div>
<span className="text-sm font-medium">
{contact?.display_name ?? share.user_id}
</span>
{contact?.display_name && (
<span className="ml-1.5 text-xs text-muted-foreground font-mono">
{share.user_id}
</span>
)}
{contact?.username && (
<span className="ml-1.5 text-xs text-muted-foreground">
@{contact.username}
</span>
)}
{share.granted_by && (
<span className="ml-2 text-xs text-muted-foreground">
by {contactByGrant?.display_name ?? share.granted_by}
</span>
)}
</div>
<Badge variant={roleBadgeVariant(share.role)}>{share.role}</Badge>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => setRevokeTarget(share.user_id)}
>
<Trash2 className="h-3.5 w-3.5 text-destructive" />
</Button>
</div>
);
})}
</div>
)}
<ConfirmDialog
open={!!revokeTarget}
onOpenChange={() => setRevokeTarget(null)}
title={t("shares.revokeTitle")}
description={t("shares.revokeDesc", { userId: revokeTarget })}
confirmLabel={t("shares.revoke")}
variant="destructive"
onConfirm={async () => {
if (revokeTarget) {
try {
await revokeShare(revokeTarget);
} catch {
// ignore
}
setRevokeTarget(null);
}
}}
/>
</div>
);
}
@@ -31,12 +31,18 @@ export function AgentSkillsTab({ agentId }: AgentSkillsTabProps) {
const [search, setSearch] = useState("");
const [toggling, setToggling] = useState<string | null>(null);
const filtered = skills.filter(
(s) =>
s.name.toLowerCase().includes(search.toLowerCase()) ||
s.slug.toLowerCase().includes(search.toLowerCase()) ||
s.description.toLowerCase().includes(search.toLowerCase()),
);
const filtered = skills
.filter(
(s) =>
s.name.toLowerCase().includes(search.toLowerCase()) ||
s.slug.toLowerCase().includes(search.toLowerCase()) ||
s.description.toLowerCase().includes(search.toLowerCase()),
)
.sort((a, b) => {
// Priority: ungranted non-system (0) → system (1) → granted (2)
const rank = (s: typeof a) => s.is_system ? 1 : s.granted ? 2 : 0;
return rank(a) - rank(b);
});
const handleToggle = async (skillId: string, granted: boolean) => {
setToggling(skillId);
@@ -5,6 +5,5 @@ export { CompactionSection } from "./compaction-section";
export { ContextPruningSection } from "./context-pruning-section";
export { SandboxSection } from "./sandbox-section";
export { MemorySection } from "./memory-section";
export { OtherConfigSection } from "./other-config-section";
export { ThinkingSection } from "./thinking-section";
export { WorkspaceSharingSection } from "./workspace-sharing-section";
@@ -1,59 +0,0 @@
import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Textarea } from "@/components/ui/textarea";
import { Button } from "@/components/ui/button";
import { ConfigSection } from "./config-section";
interface OtherConfigSectionProps {
enabled: boolean;
value: string;
onToggle: (v: boolean) => void;
onChange: (v: string) => void;
}
export function OtherConfigSection({ enabled, value, onToggle, onChange }: OtherConfigSectionProps) {
const { t } = useTranslation("agents");
const s = "configSections.otherConfig";
const [validJson, setValidJson] = useState(true);
useEffect(() => {
try {
JSON.parse(value || "{}");
setValidJson(true);
} catch {
setValidJson(false);
}
}, [value]);
const handleFormat = () => {
try {
const parsed = JSON.parse(value);
onChange(JSON.stringify(parsed, null, 2));
} catch {
// can't format invalid JSON
}
};
return (
<ConfigSection
title={t(`${s}.title`)}
description={t(`${s}.description`)}
enabled={enabled}
onToggle={onToggle}
>
<Textarea
className={`font-mono text-sm ${!validJson ? "border-destructive" : ""}`}
rows={6}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder='{ "key": "value" }'
/>
<div className="flex items-center justify-between">
<Button variant="ghost" size="sm" onClick={handleFormat} disabled={!validJson} className="h-7 px-2 text-xs">
{t(`${s}.formatJson`)}
</Button>
{!validJson && <span className="text-xs text-destructive">{t(`${s}.invalidJson`)}</span>}
</div>
</ConfigSection>
);
}
@@ -1,11 +0,0 @@
export { LinkCreateForm } from "./link-create-form";
export { LinkList } from "./link-list";
export { LinkEditDialog } from "./link-edit-dialog";
export {
DIRECTION_OPTIONS,
directionBadgeVariant,
linkTargetName,
buildSettings,
parseCommaSeparated,
joinArray,
} from "./link-utils";
@@ -1,175 +0,0 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Plus, ChevronDown, ChevronRight } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Combobox, type ComboboxOption } from "@/components/ui/combobox";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { AgentLinkSettings } from "@/types/agent";
import { DIRECTION_OPTIONS, buildSettings } from "./link-utils";
interface LinkCreateFormProps {
agentOptions: ComboboxOption[];
onSubmit: (params: {
targetAgent: string;
direction: string;
description?: string;
maxConcurrent?: number;
settings?: AgentLinkSettings;
}) => Promise<void>;
}
export function LinkCreateForm({ agentOptions, onSubmit }: LinkCreateFormProps) {
const { t } = useTranslation("agents");
const [targetAgent, setTargetAgent] = useState("");
const [direction, setDirection] = useState("outbound");
const [description, setDescription] = useState("");
const [maxConcurrent, setMaxConcurrent] = useState("3");
const [showAdvanced, setShowAdvanced] = useState(false);
const [userAllow, setUserAllow] = useState("");
const [userDeny, setUserDeny] = useState("");
const [creating, setCreating] = useState(false);
const isValidTarget = agentOptions.some((o) => o.value === targetAgent);
const handleCreate = async () => {
if (!isValidTarget) return;
setCreating(true);
try {
await onSubmit({
targetAgent,
direction,
description: description || undefined,
maxConcurrent: parseInt(maxConcurrent, 10) || 3,
settings: buildSettings(userAllow, userDeny),
});
setTargetAgent("");
setDirection("outbound");
setDescription("");
setMaxConcurrent("3");
setUserAllow("");
setUserDeny("");
setShowAdvanced(false);
} catch {
// ignore
} finally {
setCreating(false);
}
};
return (
<div className="rounded-lg border p-4">
<h3 className="mb-3 text-sm font-medium">{t("links.createLink")}</h3>
<div className="space-y-3">
<div className="flex flex-col gap-3 sm:flex-row sm:items-end">
<div className="flex-1 space-y-1.5">
<Label>{t("links.targetAgent")}</Label>
<Combobox
value={targetAgent}
onChange={setTargetAgent}
options={agentOptions}
placeholder={t("links.selectAgent")}
/>
</div>
<div className="w-full space-y-1.5 sm:w-44">
<Label>{t("links.direction")}</Label>
<Select value={direction} onValueChange={setDirection}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{DIRECTION_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
<span className="flex flex-col">
<span>{opt.label}</span>
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<p className="text-xs text-muted-foreground">
{DIRECTION_OPTIONS.find((o) => o.value === direction)?.desc}
</p>
<div className="flex flex-col gap-3 sm:flex-row sm:items-end">
<div className="flex-1 space-y-1.5">
<Label>{t("links.description")}</Label>
<Input
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder={t("links.descriptionPlaceholder")}
/>
</div>
<div className="w-full space-y-1.5 sm:w-32">
<Label>{t("links.maxConcurrent")}</Label>
<Input
type="number"
min={1}
value={maxConcurrent}
onChange={(e) => setMaxConcurrent(e.target.value)}
/>
</div>
</div>
{/* Advanced settings */}
<button
type="button"
onClick={() => setShowAdvanced(!showAdvanced)}
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
>
{showAdvanced ? (
<ChevronDown className="h-3.5 w-3.5" />
) : (
<ChevronRight className="h-3.5 w-3.5" />
)}
{t("links.advancedSettings")}
</button>
{showAdvanced && (
<div className="space-y-3 rounded-md border border-dashed p-3">
<div className="space-y-1.5">
<Label>{t("links.allowedUsers")}</Label>
<Input
value={userAllow}
onChange={(e) => setUserAllow(e.target.value)}
placeholder={t("links.allowedUsersPlaceholder")}
/>
<p className="text-xs text-muted-foreground">
{t("links.allowedUsersHint")}
</p>
</div>
<div className="space-y-1.5">
<Label>{t("links.deniedUsers")}</Label>
<Input
value={userDeny}
onChange={(e) => setUserDeny(e.target.value)}
placeholder={t("links.deniedUsersPlaceholder")}
/>
<p className="text-xs text-muted-foreground">
{t("links.deniedUsersHint")}
</p>
</div>
</div>
)}
<div className="flex justify-end">
<Button
onClick={handleCreate}
disabled={!isValidTarget || creating}
className="gap-1.5"
>
<Plus className="h-4 w-4" />
{creating ? t("links.creating") : t("links.createLink")}
</Button>
</div>
</div>
</div>
);
}
@@ -1,146 +0,0 @@
import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import type { AgentLinkData } from "@/types/agent";
import { DIRECTION_OPTIONS, joinArray, buildSettings } from "./link-utils";
interface LinkEditDialogProps {
link: AgentLinkData | null;
onClose: () => void;
onSave: (linkId: string, updates: Record<string, unknown>) => Promise<void>;
}
export function LinkEditDialog({ link, onClose, onSave }: LinkEditDialogProps) {
const { t } = useTranslation("agents");
const [direction, setDirection] = useState("outbound");
const [description, setDescription] = useState("");
const [maxConcurrent, setMaxConcurrent] = useState("3");
const [userAllow, setUserAllow] = useState("");
const [userDeny, setUserDeny] = useState("");
const [saving, setSaving] = useState(false);
useEffect(() => {
if (link) {
setDirection(link.direction);
setDescription(link.description ?? "");
setMaxConcurrent(String(link.max_concurrent));
setUserAllow(joinArray(link.settings?.user_allow));
setUserDeny(joinArray(link.settings?.user_deny));
}
}, [link]);
const handleSave = async () => {
if (!link) return;
setSaving(true);
try {
await onSave(link.id, {
direction,
description,
maxConcurrent: parseInt(maxConcurrent, 10) || 3,
settings: buildSettings(userAllow, userDeny),
});
onClose();
} catch {
// ignore
} finally {
setSaving(false);
}
};
return (
<Dialog open={!!link} onOpenChange={(open) => !open && onClose()}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("links.editTitle")}</DialogTitle>
<DialogDescription>
{t("links.editDescription")}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-1.5">
<Label>{t("links.direction")}</Label>
<Select value={direction} onValueChange={setDirection}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{DIRECTION_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{DIRECTION_OPTIONS.find((o) => o.value === direction)?.desc}
</p>
</div>
<div className="space-y-1.5">
<Label>{t("links.description")}</Label>
<Input
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder={t("links.optionalDescription")}
/>
</div>
<div className="space-y-1.5">
<Label>{t("links.maxConcurrent")}</Label>
<Input
type="number"
min={1}
value={maxConcurrent}
onChange={(e) => setMaxConcurrent(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label>{t("links.allowedUsers")}</Label>
<Input
value={userAllow}
onChange={(e) => setUserAllow(e.target.value)}
placeholder="user1, user2, ..."
/>
<p className="text-xs text-muted-foreground">
{t("links.allowedUsersHintShort")}
</p>
</div>
<div className="space-y-1.5">
<Label>{t("links.deniedUsers")}</Label>
<Input
value={userDeny}
onChange={(e) => setUserDeny(e.target.value)}
placeholder="user3, user4, ..."
/>
<p className="text-xs text-muted-foreground">
{t("links.deniedUsersHintShort")}
</p>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose} disabled={saving}>
{t("files.cancel")}
</Button>
<Button onClick={handleSave} disabled={saving}>
{saving ? t("config.saving") : t("general.saveChanges")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -1,120 +0,0 @@
import { useTranslation } from "react-i18next";
import { Link2, Pencil, Trash2, Users } from "lucide-react";
import { Link } from "react-router";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Switch } from "@/components/ui/switch";
import type { AgentLinkData } from "@/types/agent";
import { directionBadgeVariant, effectiveDirection, linkTargetName } from "./link-utils";
interface LinkListProps {
links: AgentLinkData[];
loading: boolean;
agentId: string;
onStatusToggle: (link: AgentLinkData) => void;
onEdit: (link: AgentLinkData) => void;
onDelete: (link: AgentLinkData) => void;
}
export function LinkList({
links,
loading,
agentId,
onStatusToggle,
onEdit,
onDelete,
}: LinkListProps) {
const { t } = useTranslation("agents");
if (loading && links.length === 0) {
return (
<div className="py-8 text-center text-sm text-muted-foreground">
{t("links.loadingLinks")}
</div>
);
}
if (links.length === 0) {
return (
<div className="flex flex-col items-center gap-2 py-8 text-center">
<Link2 className="h-8 w-8 text-muted-foreground/50" />
<p className="text-sm text-muted-foreground">{t("links.noLinks")}</p>
<p className="text-xs text-muted-foreground">
{t("links.noLinksDesc")}
</p>
</div>
);
}
return (
<div className="overflow-x-auto rounded-lg border">
<div className="grid min-w-[500px] grid-cols-[1fr_100px_60px_60px_80px] items-center gap-2 border-b bg-muted/50 px-4 py-2.5 text-xs font-medium text-muted-foreground">
<span>{t("links.columns.target")}</span>
<span>{t("links.columns.direction")}</span>
<span>{t("links.columns.status")}</span>
<span>{t("links.columns.limit")}</span>
<span />
</div>
{links.map((link) => (
<div
key={link.id}
className="grid min-w-[500px] grid-cols-[1fr_100px_60px_60px_80px] items-center gap-2 border-b px-4 py-3 last:border-0"
>
<div className="min-w-0">
<div className="flex items-center gap-1.5">
<span className="truncate text-sm font-medium">
{linkTargetName(link, agentId)}
</span>
{link.team_id && (
<Link to={`/teams/${link.team_id}`}>
<Badge variant="outline" className="gap-0.5 text-[10px] px-1.5 py-0 hover:bg-muted cursor-pointer">
<Users className="h-3 w-3" />
{link.team_name || "Team"}
</Badge>
</Link>
)}
</div>
{link.target_description && link.source_agent_id === agentId && (
<p className="truncate text-xs text-muted-foreground">
{link.target_description}
</p>
)}
{link.description && (
<p className="truncate text-xs text-muted-foreground/70">
{link.description}
</p>
)}
</div>
<Badge variant={directionBadgeVariant(effectiveDirection(link, agentId))}>
{effectiveDirection(link, agentId)}
</Badge>
<Switch
checked={link.status === "active"}
onCheckedChange={() => onStatusToggle(link)}
/>
<span className="text-sm text-muted-foreground">
{link.max_concurrent}
</span>
<div className="flex gap-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => onEdit(link)}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => onDelete(link)}
>
<Trash2 className="h-3.5 w-3.5 text-destructive" />
</Button>
</div>
</div>
))}
</div>
);
}
@@ -1,47 +0,0 @@
import type { AgentLinkData, AgentLinkSettings } from "@/types/agent";
export const DIRECTION_OPTIONS = [
{ value: "outbound", label: "Outbound", desc: "This agent can delegate to the target" },
{ value: "inbound", label: "Inbound", desc: "The target can delegate to this agent" },
{ value: "bidirectional", label: "Bidirectional", desc: "Both agents can delegate to each other" },
] as const;
export function directionBadgeVariant(direction: string) {
switch (direction) {
case "outbound": return "info" as const;
case "inbound": return "warning" as const;
case "bidirectional": return "success" as const;
default: return "outline" as const;
}
}
export function parseCommaSeparated(val: string): string[] {
return val.split(",").map((s) => s.trim()).filter(Boolean);
}
export function joinArray(arr?: string[]): string {
return (arr ?? []).join(", ");
}
export function linkTargetName(link: AgentLinkData, currentAgentId: string): string {
if (link.source_agent_id === currentAgentId) {
return link.target_display_name || link.target_agent_key || link.target_agent_id;
}
return link.source_agent_key || link.source_agent_id;
}
export function effectiveDirection(link: AgentLinkData, currentAgentId: string): string {
if (link.direction === "bidirectional") return "bidirectional";
if (link.source_agent_id === currentAgentId) return link.direction;
return link.direction === "outbound" ? "inbound" : "outbound";
}
export function buildSettings(userAllow: string, userDeny: string): AgentLinkSettings | undefined {
const allow = parseCommaSeparated(userAllow);
const deny = parseCommaSeparated(userDeny);
if (allow.length === 0 && deny.length === 0) return undefined;
return {
...(allow.length > 0 ? { user_allow: allow } : {}),
...(deny.length > 0 ? { user_deny: deny } : {}),
};
}
@@ -1,67 +0,0 @@
import { useState, useCallback } from "react";
import { useWs } from "@/hooks/use-ws";
import { useAuthStore } from "@/stores/use-auth-store";
import { Methods } from "@/api/protocol";
import type { AgentLinkData, AgentLinkSettings } from "@/types/agent";
export function useAgentLinks(agentId: string) {
const ws = useWs();
const connected = useAuthStore((s) => s.connected);
const [links, setLinks] = useState<AgentLinkData[]>([]);
const [loading, setLoading] = useState(true);
const load = useCallback(async () => {
if (!connected) return;
setLoading(true);
try {
const res = await ws.call<{ links: AgentLinkData[]; count: number }>(
Methods.AGENTS_LINKS_LIST,
{ agentId, direction: "all" },
);
setLinks(res.links ?? []);
} catch {
// ignore
} finally {
setLoading(false);
}
}, [ws, agentId, connected]);
const createLink = useCallback(
async (params: {
targetAgent: string;
direction: string;
description?: string;
maxConcurrent?: number;
settings?: AgentLinkSettings;
}) => {
await ws.call(Methods.AGENTS_LINKS_CREATE, {
sourceAgent: agentId,
targetAgent: params.targetAgent,
direction: params.direction,
description: params.description,
maxConcurrent: params.maxConcurrent,
settings: params.settings,
});
load();
},
[ws, agentId, load],
);
const updateLink = useCallback(
async (linkId: string, updates: Record<string, unknown>) => {
await ws.call(Methods.AGENTS_LINKS_UPDATE, { linkId, ...updates });
load();
},
[ws, load],
);
const deleteLink = useCallback(
async (linkId: string) => {
await ws.call(Methods.AGENTS_LINKS_DELETE, { linkId });
load();
},
[ws, load],
);
return { links, loading, load, createLink, updateLink, deleteLink };
}
@@ -1,48 +0,0 @@
import { useState, useEffect, useCallback } from "react";
import { useHttp } from "@/hooks/use-ws";
import type { AgentShareData } from "@/types/agent";
export function useAgentShares(agentId: string) {
const http = useHttp();
const [shares, setShares] = useState<AgentShareData[]>([]);
const [loading, setLoading] = useState(false);
const loadShares = useCallback(async () => {
setLoading(true);
try {
const res = await http.get<{ shares: AgentShareData[] }>(
`/v1/agents/${agentId}/shares`,
);
setShares(res.shares ?? []);
} catch {
// ignore
} finally {
setLoading(false);
}
}, [http, agentId]);
useEffect(() => {
loadShares();
}, [loadShares]);
const addShare = useCallback(
async (userId: string, role: string) => {
await http.post(`/v1/agents/${agentId}/shares`, {
user_id: userId,
role,
});
loadShares();
},
[http, agentId, loadShares],
);
const revokeShare = useCallback(
async (userId: string) => {
await http.delete(`/v1/agents/${agentId}/shares/${userId}`);
loadShares();
},
[http, agentId, loadShares],
);
return { shares, loading, refresh: loadShares, addShare, revokeShare };
}
@@ -24,7 +24,7 @@ export function useContactSearch(search: string) {
const params: Record<string, string> = { limit: "20" };
if (debouncedSearch) params.search = debouncedSearch;
const res = await http.get<{ contacts: ChannelContact[] }>("/v1/contacts", params);
return res.contacts ?? [];
return Array.isArray(res.contacts) ? res.contacts : [];
},
enabled: debouncedSearch.length >= 2,
staleTime: 30_000,