+
diff --git a/ui/web/src/components/layout/topbar.tsx b/ui/web/src/components/layout/topbar.tsx
index 0caa9e5d..b8f83b45 100644
--- a/ui/web/src/components/layout/topbar.tsx
+++ b/ui/web/src/components/layout/topbar.tsx
@@ -17,7 +17,7 @@ export function Topbar() {
)}
+ {/* Expertise summary */}
+ {agent.frontmatter && (
+
+ {agent.frontmatter}
+
+ )}
+
{/* Bottom badges */}
{agent.agent_type}
diff --git a/ui/web/src/pages/agents/agent-detail/agent-detail-page.tsx b/ui/web/src/pages/agents/agent-detail/agent-detail-page.tsx
index c1cab74c..03ac5268 100644
--- a/ui/web/src/pages/agents/agent-detail/agent-detail-page.tsx
+++ b/ui/web/src/pages/agents/agent-detail/agent-detail-page.tsx
@@ -8,6 +8,7 @@ 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 { SummoningModal } from "../summoning-modal";
import { DeferredSpinner } from "@/components/shared/loading-skeleton";
@@ -102,6 +103,7 @@ export function AgentDetailPage({ agentId, onBack }: AgentDetailPageProps) {
Config
Files
Shares
+
Links
@@ -125,6 +127,10 @@ export function AgentDetailPage({ agentId, onBack }: AgentDetailPageProps) {
+
+
+
+
diff --git a/ui/web/src/pages/agents/agent-detail/agent-files-tab.tsx b/ui/web/src/pages/agents/agent-detail/agent-files-tab.tsx
index bcf036e2..9602ea12 100644
--- a/ui/web/src/pages/agents/agent-detail/agent-files-tab.tsx
+++ b/ui/web/src/pages/agents/agent-detail/agent-files-tab.tsx
@@ -1,17 +1,14 @@
import { useState, useEffect } from "react";
-import { FileText, Save, Info, Lock, Sparkles } from "lucide-react";
+import { Lock, Sparkles } from "lucide-react";
import { Button } from "@/components/ui/button";
-import { Badge } from "@/components/ui/badge";
-import { Textarea } from "@/components/ui/textarea";
-import {
- Dialog,
- DialogContent,
- DialogHeader,
- DialogTitle,
- DialogFooter,
-} from "@/components/ui/dialog";
import { useAuthStore } from "@/stores/use-auth-store";
import type { AgentData, BootstrapFile } from "@/types/agent";
+import {
+ FileSidebar,
+ FileEditor,
+ RegenerateDialog,
+ OpenAgentEmptyState,
+} from "./file-sections";
interface AgentFilesTabProps {
agent: AgentData;
@@ -21,18 +18,13 @@ interface AgentFilesTabProps {
onRegenerate?: (prompt: string) => Promise;
}
-const FILE_DESCRIPTIONS: Record = {
- "AGENTS.md": "Operating instructions and agent behavior rules",
- "SOUL.md": "Persona, tone, and behavioral boundaries",
- "TOOLS.md": "Notes about available tools and their usage",
- "IDENTITY.md": "Agent name, emoji, avatar, and description",
- "USER.md": "User profile (per-user customizable)",
- "HEARTBEAT.md": "Periodic tasks configuration",
- "BOOTSTRAP.md": "First-run ritual (auto-deleted after completion)",
- "MEMORY.json": "Long-term memory data",
-};
-
-export function AgentFilesTab({ agent, files, onGetFile, onSetFile, onRegenerate }: AgentFilesTabProps) {
+export function AgentFilesTab({
+ agent,
+ files,
+ onGetFile,
+ onSetFile,
+ onRegenerate,
+}: AgentFilesTabProps) {
const userId = useAuthStore((s) => s.userId);
const [selectedFile, setSelectedFile] = useState(null);
const [content, setContent] = useState("");
@@ -40,23 +32,23 @@ export function AgentFilesTab({ agent, files, onGetFile, onSetFile, onRegenerate
const [saving, setSaving] = useState(false);
const [dirty, setDirty] = useState(false);
const [regenerateOpen, setRegenerateOpen] = useState(false);
- const [regeneratePrompt, setRegeneratePrompt] = useState("");
- const [regenerating, setRegenerating] = useState(false);
const isOpen = agent.agent_type === "open";
const isPredefined = agent.agent_type === "predefined";
const isOwner = agent.owner_id === userId;
const canEdit = !isPredefined || isOwner;
- // Hide MEMORY.json — memory is managed by the memory system, not context files
- // Hide BOOTSTRAP.md for predefined agents — it's a per-user onboarding file, not agent-level
- const displayFiles = files.filter((f) =>
- f.name !== "MEMORY.json" && !(isPredefined && f.name === "BOOTSTRAP.md"),
+ // Hide MEMORY.json and BOOTSTRAP.md for predefined agents
+ const displayFiles = files.filter(
+ (f) =>
+ f.name !== "MEMORY.json" &&
+ !(isPredefined && f.name === "BOOTSTRAP.md"),
);
- const allMissing = displayFiles.length > 0 && displayFiles.every((f) => f.missing);
+ const allMissing =
+ displayFiles.length > 0 && displayFiles.every((f) => f.missing);
- // USER.md is per-user at runtime — not editable from the agent admin UI for predefined agents
- const isUserScoped = (fileName: string) => isPredefined && fileName === "USER.md";
+ const isUserScoped = (fileName: string) =>
+ isPredefined && fileName === "USER.md";
useEffect(() => {
if (!selectedFile) return;
@@ -80,45 +72,13 @@ export function AgentFilesTab({ agent, files, onGetFile, onSetFile, onRegenerate
}
};
- // For open agents with no agent-level files, show explanation
- if (isOpen && allMissing) {
- return (
-
-
-
-
-
Open Agent - Per-User Context Files
-
- This is an open agent. Context files (AGENTS.md, SOUL.md, TOOLS.md, etc.)
- are personalized for each user. They are automatically created from templates when a user
- first chats with this agent.
-
-
- Agent-level files shown here are empty because open agents store all context per-user
- in the user_context_files table.
-
-
-
+ const handleContentChange = (val: string) => {
+ setContent(val);
+ setDirty(true);
+ };
-
-
Context Files
-
- {displayFiles.map((file) => (
-
-
-
-
{file.name}
-
- {FILE_DESCRIPTIONS[file.name] || "Context file"}
-
-
-
per-user
-
- ))}
-
-
-
- );
+ if (isOpen && allMissing) {
+ return ;
}
return (
@@ -150,135 +110,31 @@ export function AgentFilesTab({ agent, files, onGetFile, onSetFile, onRegenerate
)}
- {/* File list */}
-
- {displayFiles.map((file) => {
- const userScoped = isUserScoped(file.name);
- return (
- !userScoped && setSelectedFile(file.name)}
- disabled={userScoped}
- className={`flex w-full items-center gap-2 rounded-md px-2 py-2 text-sm transition-colors ${
- userScoped
- ? "cursor-not-allowed opacity-60"
- : selectedFile === file.name
- ? "bg-accent text-accent-foreground"
- : "hover:bg-muted"
- }`}
- >
-
- {file.name}
- {userScoped ? (
- per-user
- ) : file.missing ? (
- empty
- ) : (
-
- {file.size > 1024 ? `${(file.size / 1024).toFixed(1)}K` : `${file.size}B`}
-
- )}
-
- );
- })}
-
-
- {/* Editor */}
-
- {selectedFile ? (
- <>
-
-
- {selectedFile}
- {FILE_DESCRIPTIONS[selectedFile] && (
-
- - {FILE_DESCRIPTIONS[selectedFile]}
-
- )}
-
- {canEdit && (
-
- {!saving && }
- {saving ? "Saving..." : "Save"}
-
- )}
-
- {loading && !content ? (
-
- Loading...
-
- ) : (
-
+
+
- {/* Edit with AI dialog */}
-
+ {onRegenerate && (
+
+ )}
);
}
diff --git a/ui/web/src/pages/agents/agent-detail/agent-general-tab.tsx b/ui/web/src/pages/agents/agent-detail/agent-general-tab.tsx
index c659bd75..86e9b5bb 100644
--- a/ui/web/src/pages/agents/agent-detail/agent-general-tab.tsx
+++ b/ui/web/src/pages/agents/agent-detail/agent-general-tab.tsx
@@ -1,22 +1,9 @@
-import { useState, useMemo, useEffect } from "react";
-import { Save, Copy, Check, AlertCircle } from "lucide-react";
+import { useState, useCallback } from "react";
+import { Save, Check, AlertCircle } from "lucide-react";
import { Button } from "@/components/ui/button";
-import { Input } from "@/components/ui/input";
-import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator";
-import { Switch } from "@/components/ui/switch";
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select";
-import { Combobox } from "@/components/ui/combobox";
-import { useProviders } from "@/pages/providers/hooks/use-providers";
-import { useProviderModels } from "@/pages/providers/hooks/use-provider-models";
-import { useProviderVerify } from "@/pages/providers/hooks/use-provider-verify";
import type { AgentData } from "@/types/agent";
+import { IdentitySection, LlmConfigSection, WorkspaceSection } from "./general-sections";
interface AgentGeneralTabProps {
agent: AgentData;
@@ -24,43 +11,31 @@ interface AgentGeneralTabProps {
}
export function AgentGeneralTab({ agent, onUpdate }: AgentGeneralTabProps) {
- const { providers } = useProviders();
- const enabledProviders = providers.filter((p) => p.enabled);
-
+ // Identity
const [displayName, setDisplayName] = useState(agent.display_name ?? "");
- const [provider, setProvider] = useState(agent.provider);
- const [model, setModel] = useState(agent.model);
-
- const selectedProviderId = useMemo(
- () => enabledProviders.find((p) => p.name === provider)?.id,
- [enabledProviders, provider],
- );
- const { models, loading: modelsLoading } = useProviderModels(selectedProviderId);
- const { verify, verifying, result: verifyResult, reset: resetVerify } = useProviderVerify();
-
- // Track whether provider/model changed from saved values
- const llmChanged = provider !== agent.provider || model !== agent.model;
-
- // Reset verification when provider or model changes
- useEffect(() => {
- resetVerify();
- }, [provider, model, resetVerify]);
-
- const handleVerify = async () => {
- if (!selectedProviderId || !model.trim()) return;
- await verify(selectedProviderId, model.trim());
- };
-
- const [contextWindow, setContextWindow] = useState(agent.context_window || 200000);
- const [maxToolIterations, setMaxToolIterations] = useState(agent.max_tool_iterations || 20);
- const [restrictToWorkspace, setRestrictToWorkspace] = useState(agent.restrict_to_workspace);
+ const [frontmatter, setFrontmatter] = useState(agent.frontmatter ?? "");
const [status, setStatus] = useState(agent.status);
const [isDefault, setIsDefault] = useState(agent.is_default);
+
+ // LLM
+ const [provider, setProvider] = useState(agent.provider);
+ const [model, setModel] = useState(agent.model);
+ const [contextWindow, setContextWindow] = useState(agent.context_window || 200000);
+ const [maxToolIterations, setMaxToolIterations] = useState(agent.max_tool_iterations || 20);
+ const [llmSaveBlocked, setLlmSaveBlocked] = useState(false);
+
+ // Workspace
+ const [restrictToWorkspace, setRestrictToWorkspace] = useState(agent.restrict_to_workspace);
+
+ // Save state
const [saving, setSaving] = useState(false);
- const [copied, setCopied] = useState(false);
const [saveError, setSaveError] = useState(null);
const [saved, setSaved] = useState(false);
+ const handleSaveBlockedChange = useCallback((blocked: boolean) => {
+ setLlmSaveBlocked(blocked);
+ }, []);
+
const handleSave = async () => {
setSaving(true);
setSaveError(null);
@@ -68,6 +43,7 @@ export function AgentGeneralTab({ agent, onUpdate }: AgentGeneralTabProps) {
try {
await onUpdate({
display_name: displayName,
+ frontmatter: frontmatter || null,
provider,
model,
context_window: contextWindow,
@@ -85,185 +61,43 @@ export function AgentGeneralTab({ agent, onUpdate }: AgentGeneralTabProps) {
}
};
- const copyAgentKey = async () => {
- await navigator.clipboard.writeText(agent.agent_key);
- setCopied(true);
- setTimeout(() => setCopied(false), 2000);
- };
-
return (
- {/* Identity */}
-
- Identity
-
-
-
-
-
-
- {copied ? : }
-
-
-
Unique identifier, cannot be changed.
-
-
-
-
setDisplayName(e.target.value)}
- placeholder="e.g. My Assistant"
- />
-
- Friendly name shown in the UI. Leave empty to use the agent key.
-
-
-
-
-
-
-
-
-
-
-
+
- {/* LLM Configuration */}
-
- LLM Configuration
-
-
-
-
- {enabledProviders.length > 0 ? (
-
- ) : (
- setProvider(e.target.value)}
- placeholder="openrouter"
- />
- )}
-
-
-
-
-
- ({ value: m.id, label: m.name }))}
- placeholder={modelsLoading ? "Loading models..." : "Enter or select model"}
- />
-
- {llmChanged && (
-
- {verifying ? "..." : "Check"}
-
- )}
-
- {verifyResult && (
-
- {verifyResult.valid ? "Model verified" : verifyResult.error || "Verification failed"}
-
- )}
-
-
-
-
-
-
setContextWindow(Number(e.target.value) || 0)}
- placeholder="200000"
- />
-
Token limit for the model context.
-
-
-
-
setMaxToolIterations(Number(e.target.value) || 0)}
- placeholder="25"
- />
-
Max tool calls per request.
-
-
-
-
+
- {/* Workspace */}
-
- Workspace
-
-
-
-
- {agent.workspace || "No workspace configured"}
-
-
- Automatically assigned when the agent is created. Per-user subdirectories are created at runtime.
-
-
-
-
-
-
-
- Confine file access strictly within the workspace path.
-
-
-
-
-
+
{/* Save */}
{saveError && (
@@ -278,7 +112,7 @@ export function AgentGeneralTab({ agent, onUpdate }: AgentGeneralTabProps) {
Saved
)}
-
+
{!saving && }
{saving ? "Saving..." : "Save Changes"}
diff --git a/ui/web/src/pages/agents/agent-detail/agent-links-tab.tsx b/ui/web/src/pages/agents/agent-detail/agent-links-tab.tsx
new file mode 100644
index 00000000..74fb48c0
--- /dev/null
+++ b/ui/web/src/pages/agents/agent-detail/agent-links-tab.tsx
@@ -0,0 +1,85 @@
+import { useState, useEffect, useMemo } from "react";
+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";
+
+interface AgentLinksTabProps {
+ agentId: string;
+}
+
+export function AgentLinksTab({ agentId }: AgentLinksTabProps) {
+ const { links, loading, load, createLink, updateLink, deleteLink } =
+ useAgentLinks(agentId);
+ const { agents } = useAgents();
+
+ const [editLink, setEditLink] = useState(null);
+ const [deleteTarget, setDeleteTarget] = useState<{
+ id: string;
+ name: string;
+ } | null>(null);
+
+ useEffect(() => {
+ load();
+ }, [load]);
+
+ // Only predefined agents can be delegation targets (open agents have no agent-level context)
+ 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 (
+
+
+
+
+ setDeleteTarget({ id: link.id, name: linkTargetName(link, agentId) })
+ }
+ />
+
+ setEditLink(null)}
+ onSave={updateLink}
+ />
+
+ setDeleteTarget(null)}
+ title="Delete Link"
+ description={`Remove the delegation link to "${deleteTarget?.name}"? Agents will no longer be able to delegate to each other through this link.`}
+ confirmLabel="Delete"
+ variant="destructive"
+ onConfirm={async () => {
+ if (deleteTarget) {
+ try {
+ await deleteLink(deleteTarget.id);
+ } catch {
+ // ignore
+ }
+ setDeleteTarget(null);
+ }
+ }}
+ />
+
+ );
+}
diff --git a/ui/web/src/pages/agents/agent-detail/file-sections/file-editor.tsx b/ui/web/src/pages/agents/agent-detail/file-sections/file-editor.tsx
new file mode 100644
index 00000000..bc89b102
--- /dev/null
+++ b/ui/web/src/pages/agents/agent-detail/file-sections/file-editor.tsx
@@ -0,0 +1,71 @@
+import { Save } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { Textarea } from "@/components/ui/textarea";
+import { FILE_DESCRIPTIONS } from "./file-utils";
+
+interface FileEditorProps {
+ fileName: string | null;
+ content: string;
+ onChange: (content: string) => void;
+ loading: boolean;
+ dirty: boolean;
+ saving: boolean;
+ canEdit: boolean;
+ onSave: () => void;
+}
+
+export function FileEditor({
+ fileName,
+ content,
+ onChange,
+ loading,
+ dirty,
+ saving,
+ canEdit,
+ onSave,
+}: FileEditorProps) {
+ if (!fileName) {
+ return (
+
+ Select a file to {canEdit ? "edit" : "view"}
+
+ );
+ }
+
+ return (
+
+
+
+ {fileName}
+ {FILE_DESCRIPTIONS[fileName] && (
+
+ - {FILE_DESCRIPTIONS[fileName]}
+
+ )}
+
+ {canEdit && (
+
+ {!saving && }
+ {saving ? "Saving..." : "Save"}
+
+ )}
+
+ {loading && !content ? (
+
+ Loading...
+
+ ) : (
+
+ );
+}
diff --git a/ui/web/src/pages/agents/agent-detail/file-sections/file-sidebar.tsx b/ui/web/src/pages/agents/agent-detail/file-sections/file-sidebar.tsx
new file mode 100644
index 00000000..91f100a4
--- /dev/null
+++ b/ui/web/src/pages/agents/agent-detail/file-sections/file-sidebar.tsx
@@ -0,0 +1,60 @@
+import { FileText } from "lucide-react";
+import { Badge } from "@/components/ui/badge";
+import type { BootstrapFile } from "@/types/agent";
+
+interface FileSidebarProps {
+ files: BootstrapFile[];
+ selectedFile: string | null;
+ onSelect: (name: string) => void;
+ isUserScoped: (name: string) => boolean;
+}
+
+export function FileSidebar({
+ files,
+ selectedFile,
+ onSelect,
+ isUserScoped,
+}: FileSidebarProps) {
+ return (
+
+ {files.map((file) => {
+ const userScoped = isUserScoped(file.name);
+ return (
+ !userScoped && onSelect(file.name)}
+ disabled={userScoped}
+ className={`flex w-full items-center gap-2 rounded-md px-2 py-2 text-sm transition-colors ${
+ userScoped
+ ? "cursor-not-allowed opacity-60"
+ : selectedFile === file.name
+ ? "bg-accent text-accent-foreground"
+ : "hover:bg-muted"
+ }`}
+ >
+
+
+ {file.name}
+
+ {userScoped ? (
+
+ per-user
+
+ ) : file.missing ? (
+
+ empty
+
+ ) : (
+
+ {file.size > 1024
+ ? `${(file.size / 1024).toFixed(1)}K`
+ : `${file.size}B`}
+
+ )}
+
+ );
+ })}
+
+ );
+}
diff --git a/ui/web/src/pages/agents/agent-detail/file-sections/file-utils.ts b/ui/web/src/pages/agents/agent-detail/file-sections/file-utils.ts
new file mode 100644
index 00000000..1e55215e
--- /dev/null
+++ b/ui/web/src/pages/agents/agent-detail/file-sections/file-utils.ts
@@ -0,0 +1,10 @@
+export const FILE_DESCRIPTIONS: Record = {
+ "AGENTS.md": "Operating instructions and agent behavior rules",
+ "SOUL.md": "Persona, tone, and behavioral boundaries",
+ "TOOLS.md": "Notes about available tools and their usage",
+ "IDENTITY.md": "Agent name, emoji, avatar, and description",
+ "USER.md": "User profile (per-user customizable)",
+ "HEARTBEAT.md": "Periodic tasks configuration",
+ "BOOTSTRAP.md": "First-run ritual (auto-deleted after completion)",
+ "MEMORY.json": "Long-term memory data",
+};
diff --git a/ui/web/src/pages/agents/agent-detail/file-sections/index.ts b/ui/web/src/pages/agents/agent-detail/file-sections/index.ts
new file mode 100644
index 00000000..627067a3
--- /dev/null
+++ b/ui/web/src/pages/agents/agent-detail/file-sections/index.ts
@@ -0,0 +1,5 @@
+export { FileSidebar } from "./file-sidebar";
+export { FileEditor } from "./file-editor";
+export { RegenerateDialog } from "./regenerate-dialog";
+export { OpenAgentEmptyState } from "./open-agent-empty-state";
+export { FILE_DESCRIPTIONS } from "./file-utils";
diff --git a/ui/web/src/pages/agents/agent-detail/file-sections/open-agent-empty-state.tsx b/ui/web/src/pages/agents/agent-detail/file-sections/open-agent-empty-state.tsx
new file mode 100644
index 00000000..173ec6d3
--- /dev/null
+++ b/ui/web/src/pages/agents/agent-detail/file-sections/open-agent-empty-state.tsx
@@ -0,0 +1,58 @@
+import { FileText, Info } from "lucide-react";
+import { Badge } from "@/components/ui/badge";
+import type { BootstrapFile } from "@/types/agent";
+import { FILE_DESCRIPTIONS } from "./file-utils";
+
+interface OpenAgentEmptyStateProps {
+ files: BootstrapFile[];
+}
+
+export function OpenAgentEmptyState({ files }: OpenAgentEmptyStateProps) {
+ return (
+
+
+
+
+
Open Agent - Per-User Context Files
+
+ This is an open agent. Context files (AGENTS.md,
+ SOUL.md, TOOLS.md, etc.) are personalized for each user. They are
+ automatically created from templates when a user first chats with
+ this agent.
+
+
+ Agent-level files shown here are empty because open agents store all
+ context per-user in the{" "}
+
+ user_context_files
+ {" "}
+ table.
+
+
+
+
+
+
Context Files
+
+ {files.map((file) => (
+
+
+
+
{file.name}
+
+ {FILE_DESCRIPTIONS[file.name] || "Context file"}
+
+
+
+ per-user
+
+
+ ))}
+
+
+
+ );
+}
diff --git a/ui/web/src/pages/agents/agent-detail/file-sections/regenerate-dialog.tsx b/ui/web/src/pages/agents/agent-detail/file-sections/regenerate-dialog.tsx
new file mode 100644
index 00000000..6bb2e0ec
--- /dev/null
+++ b/ui/web/src/pages/agents/agent-detail/file-sections/regenerate-dialog.tsx
@@ -0,0 +1,80 @@
+import { useState } from "react";
+import { Sparkles } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { Textarea } from "@/components/ui/textarea";
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogFooter,
+} from "@/components/ui/dialog";
+
+interface RegenerateDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ onRegenerate: (prompt: string) => Promise;
+}
+
+export function RegenerateDialog({
+ open,
+ onOpenChange,
+ onRegenerate,
+}: RegenerateDialogProps) {
+ const [prompt, setPrompt] = useState("");
+ const [regenerating, setRegenerating] = useState(false);
+
+ const handleSubmit = async () => {
+ if (!prompt.trim()) return;
+ setRegenerating(true);
+ try {
+ await onRegenerate(prompt.trim());
+ onOpenChange(false);
+ setPrompt("");
+ } finally {
+ setRegenerating(false);
+ }
+ };
+
+ return (
+
+ );
+}
diff --git a/ui/web/src/pages/agents/agent-detail/general-sections/identity-section.tsx b/ui/web/src/pages/agents/agent-detail/general-sections/identity-section.tsx
new file mode 100644
index 00000000..204b8fc1
--- /dev/null
+++ b/ui/web/src/pages/agents/agent-detail/general-sections/identity-section.tsx
@@ -0,0 +1,123 @@
+import { useState } from "react";
+import { Copy, Check } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Switch } from "@/components/ui/switch";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+
+interface IdentitySectionProps {
+ agentKey: string;
+ displayName: string;
+ onDisplayNameChange: (v: string) => void;
+ frontmatter: string;
+ onFrontmatterChange: (v: string) => void;
+ status: string;
+ onStatusChange: (v: string) => void;
+ isDefault: boolean;
+ onIsDefaultChange: (v: boolean) => void;
+}
+
+export function IdentitySection({
+ agentKey,
+ displayName,
+ onDisplayNameChange,
+ frontmatter,
+ onFrontmatterChange,
+ status,
+ onStatusChange,
+ isDefault,
+ onIsDefaultChange,
+}: IdentitySectionProps) {
+ const [copied, setCopied] = useState(false);
+
+ const copyAgentKey = async () => {
+ await navigator.clipboard.writeText(agentKey);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ };
+
+ return (
+
+ Identity
+
+
+
+
+
+
+ {copied ? (
+
+ ) : (
+
+ )}
+
+
+
+ Unique identifier, cannot be changed.
+
+
+
+
+
onDisplayNameChange(e.target.value)}
+ placeholder="e.g. My Assistant"
+ />
+
+ Friendly name shown in the UI. Leave empty to use the agent key.
+
+
+
+
+
onFrontmatterChange(e.target.value)}
+ placeholder="e.g. Chiêm tinh, bói toán, tử vi, thần số học"
+ />
+
+ Short description of this agent's expertise. Used for delegation discovery.
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/ui/web/src/pages/agents/agent-detail/general-sections/index.ts b/ui/web/src/pages/agents/agent-detail/general-sections/index.ts
new file mode 100644
index 00000000..cc4a1091
--- /dev/null
+++ b/ui/web/src/pages/agents/agent-detail/general-sections/index.ts
@@ -0,0 +1,3 @@
+export { IdentitySection } from "./identity-section";
+export { LlmConfigSection } from "./llm-config-section";
+export { WorkspaceSection } from "./workspace-section";
diff --git a/ui/web/src/pages/agents/agent-detail/general-sections/llm-config-section.tsx b/ui/web/src/pages/agents/agent-detail/general-sections/llm-config-section.tsx
new file mode 100644
index 00000000..532aa374
--- /dev/null
+++ b/ui/web/src/pages/agents/agent-detail/general-sections/llm-config-section.tsx
@@ -0,0 +1,163 @@
+import { useMemo, useEffect } from "react";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Button } from "@/components/ui/button";
+import { Combobox } from "@/components/ui/combobox";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { useProviders } from "@/pages/providers/hooks/use-providers";
+import { useProviderModels } from "@/pages/providers/hooks/use-provider-models";
+import { useProviderVerify } from "@/pages/providers/hooks/use-provider-verify";
+
+interface LlmConfigSectionProps {
+ provider: string;
+ onProviderChange: (v: string) => void;
+ model: string;
+ onModelChange: (v: string) => void;
+ contextWindow: number;
+ onContextWindowChange: (v: number) => void;
+ maxToolIterations: number;
+ onMaxToolIterationsChange: (v: number) => void;
+ savedProvider: string;
+ savedModel: string;
+ /** Called when verification status changes. True = save should be blocked. */
+ onSaveBlockedChange?: (blocked: boolean) => void;
+}
+
+export function LlmConfigSection({
+ provider,
+ onProviderChange,
+ model,
+ onModelChange,
+ contextWindow,
+ onContextWindowChange,
+ maxToolIterations,
+ onMaxToolIterationsChange,
+ savedProvider,
+ savedModel,
+ onSaveBlockedChange,
+}: LlmConfigSectionProps) {
+ const { providers } = useProviders();
+ const enabledProviders = providers.filter((p) => p.enabled);
+
+ const selectedProviderId = useMemo(
+ () => enabledProviders.find((p) => p.name === provider)?.id,
+ [enabledProviders, provider],
+ );
+ const { models, loading: modelsLoading } = useProviderModels(selectedProviderId);
+ const { verify, verifying, result: verifyResult, reset: resetVerify } = useProviderVerify();
+
+ const llmChanged = provider !== savedProvider || model !== savedModel;
+
+ useEffect(() => {
+ resetVerify();
+ }, [provider, model, resetVerify]);
+
+ // Report save-blocked status to parent
+ useEffect(() => {
+ onSaveBlockedChange?.(llmChanged && !verifyResult?.valid);
+ }, [llmChanged, verifyResult, onSaveBlockedChange]);
+
+ const handleVerify = async () => {
+ if (!selectedProviderId || !model.trim()) return;
+ await verify(selectedProviderId, model.trim());
+ };
+
+ return (
+
+ LLM Configuration
+
+
+
+
+ {enabledProviders.length > 0 ? (
+
+ ) : (
+ onProviderChange(e.target.value)}
+ placeholder="openrouter"
+ />
+ )}
+
+
+
+
+
+ ({ value: m.id, label: m.name }))}
+ placeholder={modelsLoading ? "Loading models..." : "Enter or select model"}
+ />
+
+ {llmChanged && (
+
+ {verifying ? "..." : "Check"}
+
+ )}
+
+ {verifyResult && (
+
+ {verifyResult.valid ? "Model verified" : verifyResult.error || "Verification failed"}
+
+ )}
+
+
+
+
+
+
onContextWindowChange(Number(e.target.value) || 0)}
+ placeholder="200000"
+ />
+
Token limit for the model context.
+
+
+
+
onMaxToolIterationsChange(Number(e.target.value) || 0)}
+ placeholder="25"
+ />
+
Max tool calls per request.
+
+
+
+
+ );
+}
diff --git a/ui/web/src/pages/agents/agent-detail/general-sections/workspace-section.tsx b/ui/web/src/pages/agents/agent-detail/general-sections/workspace-section.tsx
new file mode 100644
index 00000000..69b9a022
--- /dev/null
+++ b/ui/web/src/pages/agents/agent-detail/general-sections/workspace-section.tsx
@@ -0,0 +1,44 @@
+import { Label } from "@/components/ui/label";
+import { Switch } from "@/components/ui/switch";
+
+interface WorkspaceSectionProps {
+ workspace: string;
+ restrictToWorkspace: boolean;
+ onRestrictChange: (v: boolean) => void;
+}
+
+export function WorkspaceSection({
+ workspace,
+ restrictToWorkspace,
+ onRestrictChange,
+}: WorkspaceSectionProps) {
+ return (
+
+ Workspace
+
+
+
+
+ {workspace || "No workspace configured"}
+
+
+ Automatically assigned when the agent is created. Per-user
+ subdirectories are created at runtime.
+
+
+
+
+
+
+
+ Confine file access strictly within the workspace path.
+
+
+
+
+
+ );
+}
diff --git a/ui/web/src/pages/agents/agent-detail/link-sections/index.ts b/ui/web/src/pages/agents/agent-detail/link-sections/index.ts
new file mode 100644
index 00000000..2263b062
--- /dev/null
+++ b/ui/web/src/pages/agents/agent-detail/link-sections/index.ts
@@ -0,0 +1,11 @@
+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";
diff --git a/ui/web/src/pages/agents/agent-detail/link-sections/link-create-form.tsx b/ui/web/src/pages/agents/agent-detail/link-sections/link-create-form.tsx
new file mode 100644
index 00000000..30786c85
--- /dev/null
+++ b/ui/web/src/pages/agents/agent-detail/link-sections/link-create-form.tsx
@@ -0,0 +1,173 @@
+import { useState } from "react";
+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;
+}
+
+export function LinkCreateForm({ agentOptions, onSubmit }: LinkCreateFormProps) {
+ 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 (
+
+
Create Link
+
+
+
+
+
+
+
+
+
+
+
+
+ {DIRECTION_OPTIONS.find((o) => o.value === direction)?.desc}
+
+
+
+ {/* Advanced settings */}
+
setShowAdvanced(!showAdvanced)}
+ className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
+ >
+ {showAdvanced ? (
+
+ ) : (
+
+ )}
+ Advanced Settings
+
+ {showAdvanced && (
+
+
+
+
setUserAllow(e.target.value)}
+ placeholder="user1, user2, ..."
+ />
+
+ If set, only these users can trigger this delegation. Comma-separated.
+
+
+
+
+
setUserDeny(e.target.value)}
+ placeholder="user3, user4, ..."
+ />
+
+ These users are blocked from triggering this delegation. Comma-separated.
+
+
+
+ )}
+
+
+
+
+ {creating ? "Creating..." : "Create Link"}
+
+
+
+
+ );
+}
diff --git a/ui/web/src/pages/agents/agent-detail/link-sections/link-edit-dialog.tsx b/ui/web/src/pages/agents/agent-detail/link-sections/link-edit-dialog.tsx
new file mode 100644
index 00000000..461258b8
--- /dev/null
+++ b/ui/web/src/pages/agents/agent-detail/link-sections/link-edit-dialog.tsx
@@ -0,0 +1,144 @@
+import { useState, useEffect } from "react";
+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) => Promise;
+}
+
+export function LinkEditDialog({ link, onClose, onSave }: LinkEditDialogProps) {
+ 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 (
+
+ );
+}
diff --git a/ui/web/src/pages/agents/agent-detail/link-sections/link-list.tsx b/ui/web/src/pages/agents/agent-detail/link-sections/link-list.tsx
new file mode 100644
index 00000000..b79531f9
--- /dev/null
+++ b/ui/web/src/pages/agents/agent-detail/link-sections/link-list.tsx
@@ -0,0 +1,117 @@
+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, 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) {
+ if (loading && links.length === 0) {
+ return (
+
+ Loading links...
+
+ );
+ }
+
+ if (links.length === 0) {
+ return (
+
+
+
No agent links yet
+
+ Create links to allow agents to delegate tasks to each other.
+
+
+ );
+ }
+
+ return (
+
+
+ Target
+ Direction
+ Status
+ Limit
+
+
+ {links.map((link) => (
+
+
+
+
+ {linkTargetName(link, agentId)}
+
+ {link.team_id && (
+
+
+
+ {link.team_name || "Team"}
+
+
+ )}
+
+ {link.target_description && link.source_agent_id === agentId && (
+
+ {link.target_description}
+
+ )}
+ {link.description && (
+
+ {link.description}
+
+ )}
+
+
+ {link.direction}
+
+
onStatusToggle(link)}
+ />
+
+ {link.max_concurrent}
+
+
+
onEdit(link)}
+ >
+
+
+
onDelete(link)}
+ >
+
+
+
+
+ ))}
+
+ );
+}
diff --git a/ui/web/src/pages/agents/agent-detail/link-sections/link-utils.ts b/ui/web/src/pages/agents/agent-detail/link-sections/link-utils.ts
new file mode 100644
index 00000000..2cead1de
--- /dev/null
+++ b/ui/web/src/pages/agents/agent-detail/link-sections/link-utils.ts
@@ -0,0 +1,41 @@
+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 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 } : {}),
+ };
+}
diff --git a/ui/web/src/pages/agents/hooks/use-agent-links.ts b/ui/web/src/pages/agents/hooks/use-agent-links.ts
new file mode 100644
index 00000000..e1f379e6
--- /dev/null
+++ b/ui/web/src/pages/agents/hooks/use-agent-links.ts
@@ -0,0 +1,65 @@
+import { useState, useCallback } from "react";
+import { useWs } from "@/hooks/use-ws";
+import { Methods } from "@/api/protocol";
+import type { AgentLinkData, AgentLinkSettings } from "@/types/agent";
+
+export function useAgentLinks(agentId: string) {
+ const ws = useWs();
+ const [links, setLinks] = useState([]);
+ const [loading, setLoading] = useState(false);
+
+ const load = useCallback(async () => {
+ if (!ws.isConnected) 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]);
+
+ 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) => {
+ 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 };
+}
diff --git a/ui/web/src/pages/config/sections/channels-section.tsx b/ui/web/src/pages/config/sections/channels-section.tsx
index 5e8c948b..98d0a428 100644
--- a/ui/web/src/pages/config/sections/channels-section.tsx
+++ b/ui/web/src/pages/config/sections/channels-section.tsx
@@ -101,7 +101,7 @@ export function ChannelsSection({ data, onSave, saving }: Props) {
toggle(ch)}
>
{isOpen ? : }
diff --git a/ui/web/src/pages/config/sections/providers-section.tsx b/ui/web/src/pages/config/sections/providers-section.tsx
index 349a782d..bb8bc721 100644
--- a/ui/web/src/pages/config/sections/providers-section.tsx
+++ b/ui/web/src/pages/config/sections/providers-section.tsx
@@ -99,7 +99,7 @@ export function ProvidersSection({ data, onSave, saving }: Props) {
toggle(p.key)}
>
{isOpen ? : }
diff --git a/ui/web/src/pages/sessions/session-detail-page.tsx b/ui/web/src/pages/sessions/session-detail-page.tsx
index 83d37681..c5c0dac2 100644
--- a/ui/web/src/pages/sessions/session-detail-page.tsx
+++ b/ui/web/src/pages/sessions/session-detail-page.tsx
@@ -203,7 +203,7 @@ function SystemMessageBlock({ content }: { content: string }) {
setExpanded((v) => !v)}
- className="ml-1 text-primary hover:underline"
+ className="ml-1 cursor-pointer text-primary hover:underline"
>
{expanded ? "hide" : "show details"}
@@ -246,7 +246,7 @@ function SummaryBlock({ text }: { text: string }) {
setExpanded((v) => !v)}
- className="mt-1 text-xs font-medium text-primary hover:underline"
+ className="mt-1 cursor-pointer text-xs font-medium text-primary hover:underline"
>
{expanded ? "Show less" : "Show more"}
diff --git a/ui/web/src/pages/teams/hooks/use-teams.ts b/ui/web/src/pages/teams/hooks/use-teams.ts
new file mode 100644
index 00000000..4cfc5658
--- /dev/null
+++ b/ui/web/src/pages/teams/hooks/use-teams.ts
@@ -0,0 +1,70 @@
+import { useState, useCallback } from "react";
+import { useWs } from "@/hooks/use-ws";
+import { Methods } from "@/api/protocol";
+import type { TeamData, TeamMemberData, TeamTaskData } from "@/types/team";
+
+export function useTeams() {
+ const ws = useWs();
+ const [teams, setTeams] = useState([]);
+ const [loading, setLoading] = useState(false);
+
+ const load = useCallback(async () => {
+ if (!ws.isConnected) return;
+ setLoading(true);
+ try {
+ const res = await ws.call<{ teams: TeamData[]; count: number }>(
+ Methods.TEAMS_LIST,
+ );
+ setTeams(res.teams ?? []);
+ } catch {
+ // ignore
+ } finally {
+ setLoading(false);
+ }
+ }, [ws]);
+
+ const createTeam = useCallback(
+ async (params: {
+ name: string;
+ lead: string;
+ members: string[];
+ description?: string;
+ }) => {
+ await ws.call(Methods.TEAMS_CREATE, params);
+ load();
+ },
+ [ws, load],
+ );
+
+ const deleteTeam = useCallback(
+ async (teamId: string) => {
+ await ws.call(Methods.TEAMS_DELETE, { teamId });
+ load();
+ },
+ [ws, load],
+ );
+
+ const getTeam = useCallback(
+ async (teamId: string) => {
+ const res = await ws.call<{ team: TeamData; members: TeamMemberData[] }>(
+ Methods.TEAMS_GET,
+ { teamId },
+ );
+ return res;
+ },
+ [ws],
+ );
+
+ const getTeamTasks = useCallback(
+ async (teamId: string) => {
+ const res = await ws.call<{ tasks: TeamTaskData[]; count: number }>(
+ Methods.TEAMS_TASK_LIST,
+ { teamId },
+ );
+ return res;
+ },
+ [ws],
+ );
+
+ return { teams, loading, load, createTeam, deleteTeam, getTeam, getTeamTasks };
+}
diff --git a/ui/web/src/pages/teams/member-sections/index.ts b/ui/web/src/pages/teams/member-sections/index.ts
new file mode 100644
index 00000000..da9146ec
--- /dev/null
+++ b/ui/web/src/pages/teams/member-sections/index.ts
@@ -0,0 +1,2 @@
+export { MemberList } from "./member-list";
+export { roleBadgeVariant } from "./member-utils";
diff --git a/ui/web/src/pages/teams/member-sections/member-list.tsx b/ui/web/src/pages/teams/member-sections/member-list.tsx
new file mode 100644
index 00000000..38d076dc
--- /dev/null
+++ b/ui/web/src/pages/teams/member-sections/member-list.tsx
@@ -0,0 +1,58 @@
+import { Users } from "lucide-react";
+import { Badge } from "@/components/ui/badge";
+import type { TeamMemberData } from "@/types/team";
+import { roleBadgeVariant } from "./member-utils";
+
+interface MemberListProps {
+ members: TeamMemberData[];
+}
+
+export function MemberList({ members }: MemberListProps) {
+ if (members.length === 0) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+ Agent
+ Frontmatter
+ Role
+
+ {members.map((member) => (
+
+
+
+ {member.display_name || member.agent_key || member.agent_id.slice(0, 8)}
+
+ {member.display_name && member.agent_key && (
+
+ {member.agent_key}
+
+ )}
+
+
+ {member.frontmatter ? (
+
+ {member.frontmatter}
+
+ ) : (
+
—
+ )}
+
+
+ {member.role}
+
+
+ ))}
+
+ );
+}
diff --git a/ui/web/src/pages/teams/member-sections/member-utils.ts b/ui/web/src/pages/teams/member-sections/member-utils.ts
new file mode 100644
index 00000000..bff70d78
--- /dev/null
+++ b/ui/web/src/pages/teams/member-sections/member-utils.ts
@@ -0,0 +1,7 @@
+export function roleBadgeVariant(role: string) {
+ switch (role) {
+ case "lead": return "info" as const;
+ case "member": return "outline" as const;
+ default: return "outline" as const;
+ }
+}
diff --git a/ui/web/src/pages/teams/task-sections/index.ts b/ui/web/src/pages/teams/task-sections/index.ts
new file mode 100644
index 00000000..5c6ef9da
--- /dev/null
+++ b/ui/web/src/pages/teams/task-sections/index.ts
@@ -0,0 +1,2 @@
+export { TaskList } from "./task-list";
+export { taskStatusBadgeVariant } from "./task-utils";
diff --git a/ui/web/src/pages/teams/task-sections/task-list.tsx b/ui/web/src/pages/teams/task-sections/task-list.tsx
new file mode 100644
index 00000000..0721f55d
--- /dev/null
+++ b/ui/web/src/pages/teams/task-sections/task-list.tsx
@@ -0,0 +1,71 @@
+import { ClipboardList } from "lucide-react";
+import { Badge } from "@/components/ui/badge";
+import type { TeamTaskData } from "@/types/team";
+import { taskStatusBadgeVariant } from "./task-utils";
+
+interface TaskListProps {
+ tasks: TeamTaskData[];
+ loading: boolean;
+}
+
+export function TaskList({ tasks, loading }: TaskListProps) {
+ if (loading && tasks.length === 0) {
+ return (
+
+ Loading tasks...
+
+ );
+ }
+
+ if (tasks.length === 0) {
+ return (
+
+
+
No tasks yet
+
+ Tasks are created by team agents during collaboration.
+
+
+ );
+ }
+
+ return (
+
+
+ Subject
+ Status
+ Owner
+ Priority
+
+ {tasks.map((task) => (
+
+
+
{task.subject}
+ {task.description && (
+
+ {task.description}
+
+ )}
+ {task.result && (
+
+ {task.result}
+
+ )}
+
+
+ {task.status.replace("_", " ")}
+
+
+ {task.owner_agent_key || "—"}
+
+
+ {task.priority}
+
+
+ ))}
+
+ );
+}
diff --git a/ui/web/src/pages/teams/task-sections/task-utils.ts b/ui/web/src/pages/teams/task-sections/task-utils.ts
new file mode 100644
index 00000000..3352622c
--- /dev/null
+++ b/ui/web/src/pages/teams/task-sections/task-utils.ts
@@ -0,0 +1,9 @@
+export function taskStatusBadgeVariant(status: string) {
+ switch (status) {
+ case "pending": return "outline" as const;
+ case "in_progress": return "info" as const;
+ case "completed": return "success" as const;
+ case "blocked": return "warning" as const;
+ default: return "outline" as const;
+ }
+}
diff --git a/ui/web/src/pages/teams/team-card.tsx b/ui/web/src/pages/teams/team-card.tsx
new file mode 100644
index 00000000..5cf305e3
--- /dev/null
+++ b/ui/web/src/pages/teams/team-card.tsx
@@ -0,0 +1,47 @@
+import { Users } from "lucide-react";
+import { Badge } from "@/components/ui/badge";
+import type { TeamData } from "@/types/team";
+
+interface TeamCardProps {
+ team: TeamData;
+ onClick: () => void;
+}
+
+export function TeamCard({ team, onClick }: TeamCardProps) {
+ return (
+
+ {/* Top row: icon + name + status */}
+
+
+
+
+
+ {team.name}
+
+
+ {team.status}
+
+
+
+ {/* Description */}
+ {team.description && (
+
+ {team.description}
+
+ )}
+
+ {/* Bottom badges */}
+
+ {team.lead_agent_key && (
+
+ Lead: {team.lead_agent_key}
+
+ )}
+
+
+ );
+}
diff --git a/ui/web/src/pages/teams/team-create-dialog.tsx b/ui/web/src/pages/teams/team-create-dialog.tsx
new file mode 100644
index 00000000..8c3cc1d1
--- /dev/null
+++ b/ui/web/src/pages/teams/team-create-dialog.tsx
@@ -0,0 +1,189 @@
+import { useState, useMemo, useEffect } from "react";
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogFooter,
+} from "@/components/ui/dialog";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Combobox } from "@/components/ui/combobox";
+import { Badge } from "@/components/ui/badge";
+import { X } from "lucide-react";
+import { useAgents } from "@/pages/agents/hooks/use-agents";
+
+interface TeamCreateDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ onCreate: (data: {
+ name: string;
+ lead: string;
+ members: string[];
+ description?: string;
+ }) => Promise;
+}
+
+export function TeamCreateDialog({ open, onOpenChange, onCreate }: TeamCreateDialogProps) {
+ const { agents, loading: agentsLoading, refresh: refreshAgents } = useAgents();
+ const [name, setName] = useState("");
+ const [description, setDescription] = useState("");
+ const [lead, setLead] = useState("");
+ const [memberSearch, setMemberSearch] = useState("");
+ const [members, setMembers] = useState([]);
+ const [loading, setLoading] = useState(false);
+
+ // Refresh agents when dialog opens to ensure fresh data
+ useEffect(() => {
+ if (open) refreshAgents();
+ }, [open, refreshAgents]);
+
+ // Lead can be any active agent (open or predefined) — it receives user requests
+ const leadOptions = useMemo(
+ () =>
+ agents
+ .filter((a) => a.status === "active")
+ .map((a) => ({
+ value: a.id,
+ label: a.display_name || a.agent_key,
+ })),
+ [agents],
+ );
+
+ // Members must be predefined (need agent-level context for team collaboration)
+ const memberOptions = useMemo(
+ () =>
+ agents
+ .filter((a) => a.agent_type === "predefined" && a.status === "active" && a.id !== lead && !members.includes(a.id))
+ .map((a) => ({
+ value: a.id,
+ label: a.display_name || a.agent_key,
+ })),
+ [agents, lead, members],
+ );
+
+ const addMember = (agentId: string) => {
+ if (agentId && !members.includes(agentId) && agentId !== lead) {
+ setMembers((prev) => [...prev, agentId]);
+ setMemberSearch("");
+ }
+ };
+
+ const removeMember = (agentId: string) => {
+ setMembers((prev) => prev.filter((id) => id !== agentId));
+ };
+
+ const agentLabel = (agentId: string) => {
+ const opt = leadOptions.find((o) => o.value === agentId);
+ return opt?.label || agentId.slice(0, 8);
+ };
+
+ const handleCreate = async () => {
+ if (!name.trim() || !lead) return;
+ setLoading(true);
+ try {
+ await onCreate({
+ name: name.trim(),
+ lead,
+ members,
+ description: description.trim() || undefined,
+ });
+ onOpenChange(false);
+ setName("");
+ setDescription("");
+ setLead("");
+ setMembers([]);
+ setMemberSearch("");
+ } catch {
+ // error handled upstream
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+
+ );
+}
diff --git a/ui/web/src/pages/teams/team-detail-page.tsx b/ui/web/src/pages/teams/team-detail-page.tsx
new file mode 100644
index 00000000..388a919c
--- /dev/null
+++ b/ui/web/src/pages/teams/team-detail-page.tsx
@@ -0,0 +1,104 @@
+import { useState, useEffect } from "react";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import { Button } from "@/components/ui/button";
+import { Badge } from "@/components/ui/badge";
+import { ArrowLeft, Users } from "lucide-react";
+import { DeferredSpinner } from "@/components/shared/loading-skeleton";
+import { useTeams } from "./hooks/use-teams";
+import { TeamMembersTab } from "./team-members-tab";
+import { TeamTasksTab } from "./team-tasks-tab";
+import type { TeamData, TeamMemberData } from "@/types/team";
+
+interface TeamDetailPageProps {
+ teamId: string;
+ onBack: () => void;
+}
+
+export function TeamDetailPage({ teamId, onBack }: TeamDetailPageProps) {
+ const { getTeam, getTeamTasks } = useTeams();
+ const [team, setTeam] = useState(null);
+ const [members, setMembers] = useState([]);
+ const [loading, setLoading] = useState(true);
+
+ useEffect(() => {
+ let cancelled = false;
+ (async () => {
+ setLoading(true);
+ try {
+ const res = await getTeam(teamId);
+ if (!cancelled) {
+ setTeam(res.team);
+ setMembers(res.members ?? []);
+ }
+ } catch {
+ // ignore
+ } finally {
+ if (!cancelled) setLoading(false);
+ }
+ })();
+ return () => { cancelled = true; };
+ }, [teamId, getTeam]);
+
+ if (loading || !team) {
+ return (
+
+ );
+ }
+
+ return (
+
+ {/* Header */}
+
+
+
+
+
+
+
+
+
+
{team.name}
+
+ {team.status}
+
+
+
+ {team.lead_agent_key && (
+ <>
+ Lead: {team.lead_agent_key}
+ |
+ >
+ )}
+ {members.length} member{members.length !== 1 ? "s" : ""}
+
+ {team.description && (
+
{team.description}
+ )}
+
+
+
+ {/* Tabs */}
+
+
+
+ Members
+ Tasks
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/ui/web/src/pages/teams/team-members-tab.tsx b/ui/web/src/pages/teams/team-members-tab.tsx
new file mode 100644
index 00000000..f0f92e11
--- /dev/null
+++ b/ui/web/src/pages/teams/team-members-tab.tsx
@@ -0,0 +1,14 @@
+import type { TeamMemberData } from "@/types/team";
+import { MemberList } from "./member-sections";
+
+interface TeamMembersTabProps {
+ members: TeamMemberData[];
+}
+
+export function TeamMembersTab({ members }: TeamMembersTabProps) {
+ return (
+
+
+
+ );
+}
diff --git a/ui/web/src/pages/teams/team-tasks-tab.tsx b/ui/web/src/pages/teams/team-tasks-tab.tsx
new file mode 100644
index 00000000..5943a2e9
--- /dev/null
+++ b/ui/web/src/pages/teams/team-tasks-tab.tsx
@@ -0,0 +1,35 @@
+import { useState, useEffect } from "react";
+import type { TeamTaskData } from "@/types/team";
+import { TaskList } from "./task-sections";
+
+interface TeamTasksTabProps {
+ teamId: string;
+ getTeamTasks: (teamId: string) => Promise<{ tasks: TeamTaskData[]; count: number }>;
+}
+
+export function TeamTasksTab({ teamId, getTeamTasks }: TeamTasksTabProps) {
+ const [tasks, setTasks] = useState([]);
+ const [loading, setLoading] = useState(true);
+
+ useEffect(() => {
+ let cancelled = false;
+ (async () => {
+ setLoading(true);
+ try {
+ const res = await getTeamTasks(teamId);
+ if (!cancelled) setTasks(res.tasks ?? []);
+ } catch {
+ // ignore
+ } finally {
+ if (!cancelled) setLoading(false);
+ }
+ })();
+ return () => { cancelled = true; };
+ }, [teamId, getTeamTasks]);
+
+ return (
+
+
+
+ );
+}
diff --git a/ui/web/src/pages/teams/teams-page.tsx b/ui/web/src/pages/teams/teams-page.tsx
new file mode 100644
index 00000000..ef626a11
--- /dev/null
+++ b/ui/web/src/pages/teams/teams-page.tsx
@@ -0,0 +1,141 @@
+import { useState, useEffect } from "react";
+import { useParams, useNavigate } from "react-router";
+import { Plus, Users } from "lucide-react";
+import { PageHeader } from "@/components/shared/page-header";
+import { EmptyState } from "@/components/shared/empty-state";
+import { SearchInput } from "@/components/shared/search-input";
+import { Pagination } from "@/components/shared/pagination";
+import { CardSkeleton } from "@/components/shared/loading-skeleton";
+import { useDeferredLoading } from "@/hooks/use-deferred-loading";
+import { Button } from "@/components/ui/button";
+import { ConfirmDialog } from "@/components/shared/confirm-dialog";
+import { useTeams } from "./hooks/use-teams";
+import { TeamCard } from "./team-card";
+import { TeamCreateDialog } from "./team-create-dialog";
+import { TeamDetailPage } from "./team-detail-page";
+import { usePagination } from "@/hooks/use-pagination";
+
+export function TeamsPage() {
+ const { id: detailId } = useParams<{ id: string }>();
+ const navigate = useNavigate();
+ const { teams, loading, load, createTeam, deleteTeam } = useTeams();
+ const showSkeleton = useDeferredLoading(loading && teams.length === 0);
+
+ const [search, setSearch] = useState("");
+ const [createOpen, setCreateOpen] = useState(false);
+ const [deleteTarget, setDeleteTarget] = useState<{ id: string; name: string } | null>(null);
+
+ useEffect(() => {
+ load();
+ }, [load]);
+
+ // Show detail view if route has :id
+ if (detailId) {
+ return (
+ navigate("/teams")}
+ />
+ );
+ }
+
+ const filtered = teams.filter((t) => {
+ const q = search.toLowerCase();
+ return (
+ t.name.toLowerCase().includes(q) ||
+ (t.description ?? "").toLowerCase().includes(q)
+ );
+ });
+
+ const { pageItems, pagination, setPage, setPageSize, resetPage } = usePagination(filtered);
+
+ useEffect(() => { resetPage(); }, [search, resetPage]);
+
+ return (
+
+
setCreateOpen(true)} className="gap-1">
+ Create Team
+
+ }
+ />
+
+
+
+
+
+
+ {showSkeleton ? (
+
+ {Array.from({ length: 6 }).map((_, i) => (
+
+ ))}
+
+ ) : filtered.length === 0 ? (
+
+ ) : (
+ <>
+
+ {pageItems.map((team) => (
+ navigate(`/teams/${team.id}`)}
+ />
+ ))}
+
+
+ >
+ )}
+
+
+ {
+ await createTeam(data);
+ }}
+ />
+
+ setDeleteTarget(null)}
+ title="Delete Team"
+ description={`Are you sure you want to delete "${deleteTarget?.name}"? This action cannot be undone.`}
+ confirmLabel="Delete"
+ variant="destructive"
+ onConfirm={async () => {
+ if (deleteTarget) {
+ await deleteTeam(deleteTarget.id);
+ setDeleteTarget(null);
+ }
+ }}
+ />
+
+ );
+}
diff --git a/ui/web/src/pages/traces/hooks/use-traces.ts b/ui/web/src/pages/traces/hooks/use-traces.ts
index 93f45d9b..5ccd8ccd 100644
--- a/ui/web/src/pages/traces/hooks/use-traces.ts
+++ b/ui/web/src/pages/traces/hooks/use-traces.ts
@@ -3,6 +3,7 @@ import { useHttp } from "@/hooks/use-ws";
export interface TraceData {
id: string;
+ parent_trace_id?: string;
agent_id?: string;
user_id: string;
session_key: string;
diff --git a/ui/web/src/pages/traces/trace-detail-dialog.tsx b/ui/web/src/pages/traces/trace-detail-dialog.tsx
index d86af5ab..2e895d8d 100644
--- a/ui/web/src/pages/traces/trace-detail-dialog.tsx
+++ b/ui/web/src/pages/traces/trace-detail-dialog.tsx
@@ -41,9 +41,10 @@ interface TraceDetailDialogProps {
traceId: string;
onClose: () => void;
getTrace: (id: string) => Promise<{ trace: TraceData; spans: SpanData[] } | null>;
+ onNavigateTrace?: (traceId: string) => void;
}
-export function TraceDetailDialog({ traceId, onClose, getTrace }: TraceDetailDialogProps) {
+export function TraceDetailDialog({ traceId, onClose, getTrace, onNavigateTrace }: TraceDetailDialogProps) {
const [trace, setTrace] = useState(null);
const [spans, setSpans] = useState([]);
const [loading, setLoading] = useState(true);
@@ -107,6 +108,18 @@ export function TraceDetailDialog({ traceId, onClose, getTrace }: TraceDetailDia
Started:{" "}
{formatDate(trace.start_time)}
+ {trace.parent_trace_id && (
+
+ Delegated from:{" "}
+ onNavigateTrace?.(trace.parent_trace_id!)}
+ >
+ {trace.parent_trace_id.slice(0, 8)}...
+
+
+ )}
{trace.input_preview && (
@@ -164,7 +177,7 @@ function SpanTreeNode({ node, depth }: { node: SpanNode; depth: number }) {
{hasChildren ? (
setExpanded(!expanded)}
>
{expanded ? (
@@ -180,7 +193,7 @@ function SpanTreeNode({ node, depth }: { node: SpanNode; depth: number }) {
{/* Span info row - clickable for detail */}
setDetailOpen(!detailOpen)}
>
diff --git a/ui/web/src/pages/traces/traces-page.tsx b/ui/web/src/pages/traces/traces-page.tsx
index c833c06a..ce522981 100644
--- a/ui/web/src/pages/traces/traces-page.tsx
+++ b/ui/web/src/pages/traces/traces-page.tsx
@@ -179,6 +179,7 @@ export function TracesPage() {
traceId={selectedTraceId}
onClose={() => setSelectedTraceId(null)}
getTrace={getTrace}
+ onNavigateTrace={setSelectedTraceId}
/>
)}
diff --git a/ui/web/src/routes.tsx b/ui/web/src/routes.tsx
index becd7029..588060cd 100644
--- a/ui/web/src/routes.tsx
+++ b/ui/web/src/routes.tsx
@@ -56,6 +56,9 @@ const CustomToolsPage = lazy(() =>
const MCPPage = lazy(() =>
import("@/pages/mcp/mcp-page").then((m) => ({ default: m.MCPPage })),
);
+const TeamsPage = lazy(() =>
+ import("@/pages/teams/teams-page").then((m) => ({ default: m.TeamsPage })),
+);
const TtsPage = lazy(() =>
import("@/pages/tts/tts-page").then((m) => ({ default: m.TtsPage })),
);
@@ -87,6 +90,8 @@ export function AppRoutes() {
} />
} />
} />
+ } />
+ } />
} />
} />
} />
diff --git a/ui/web/src/types/agent.ts b/ui/web/src/types/agent.ts
index 9dcfeb14..ba911e3b 100644
--- a/ui/web/src/types/agent.ts
+++ b/ui/web/src/types/agent.ts
@@ -68,6 +68,7 @@ export interface AgentData {
id: string;
agent_key: string;
display_name?: string;
+ frontmatter?: string;
owner_id: string;
provider: string;
model: string;
@@ -100,6 +101,32 @@ export interface AgentShareData {
created_at?: string;
}
+export interface AgentLinkSettings {
+ require_role?: string;
+ user_allow?: string[];
+ user_deny?: string[];
+}
+
+export interface AgentLinkData {
+ id: string;
+ source_agent_id: string;
+ target_agent_id: string;
+ direction: "outbound" | "inbound" | "bidirectional";
+ team_id?: string;
+ team_name?: string;
+ description?: string;
+ max_concurrent: number;
+ settings?: AgentLinkSettings;
+ status: "active" | "disabled";
+ created_by: string;
+ created_at?: string;
+ updated_at?: string;
+ source_agent_key?: string;
+ target_agent_key?: string;
+ target_display_name?: string;
+ target_description?: string;
+}
+
export interface AgentInfo {
id: string;
name: string;
diff --git a/ui/web/src/types/team.ts b/ui/web/src/types/team.ts
new file mode 100644
index 00000000..4ffa53ac
--- /dev/null
+++ b/ui/web/src/types/team.ts
@@ -0,0 +1,39 @@
+/** Team data types matching Go internal/store/team_store.go */
+
+export interface TeamData {
+ id: string;
+ name: string;
+ lead_agent_id: string;
+ lead_agent_key?: string;
+ description?: string;
+ status: "active" | "archived";
+ settings?: Record;
+ created_by: string;
+ created_at?: string;
+ updated_at?: string;
+}
+
+export interface TeamMemberData {
+ team_id: string;
+ agent_id: string;
+ agent_key?: string;
+ display_name?: string;
+ frontmatter?: string;
+ role: "lead" | "member";
+ joined_at?: string;
+}
+
+export interface TeamTaskData {
+ id: string;
+ team_id: string;
+ subject: string;
+ description?: string;
+ status: "pending" | "in_progress" | "completed" | "blocked";
+ owner_agent_id?: string;
+ owner_agent_key?: string;
+ blocked_by?: string[];
+ priority: number;
+ result?: string;
+ created_at?: string;
+ updated_at?: string;
+}
diff --git a/ui/web/tsconfig.tsbuildinfo b/ui/web/tsconfig.tsbuildinfo
index e5608871..ba9ac344 100644
--- a/ui/web/tsconfig.tsbuildinfo
+++ b/ui/web/tsconfig.tsbuildinfo
@@ -1 +1 @@
-{"root":["./src/app.tsx","./src/main.tsx","./src/routes.tsx","./src/vite-env.d.ts","./src/api/errors.ts","./src/api/http-client.ts","./src/api/protocol.ts","./src/api/ws-client.ts","./src/components/chat/agent-selector.tsx","./src/components/chat/chat-input.tsx","./src/components/chat/message-bubble.tsx","./src/components/chat/message-content.tsx","./src/components/chat/rich-content.tsx","./src/components/chat/session-switcher.tsx","./src/components/chat/streaming-text.tsx","./src/components/chat/thinking-indicator.tsx","./src/components/chat/tool-call-card.tsx","./src/components/layout/app-layout.tsx","./src/components/layout/connection-status.tsx","./src/components/layout/sidebar-group.tsx","./src/components/layout/sidebar-item.tsx","./src/components/layout/sidebar.tsx","./src/components/layout/topbar.tsx","./src/components/providers/app-providers.tsx","./src/components/providers/theme-provider.tsx","./src/components/providers/ws-provider.tsx","./src/components/shared/confirm-dialog.tsx","./src/components/shared/empty-state.tsx","./src/components/shared/loading-skeleton.tsx","./src/components/shared/markdown-renderer.tsx","./src/components/shared/page-header.tsx","./src/components/shared/pagination.tsx","./src/components/shared/require-auth.tsx","./src/components/shared/search-input.tsx","./src/components/shared/status-badge.tsx","./src/components/ui/alert.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/combobox.tsx","./src/components/ui/dialog.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/scroll-area.tsx","./src/components/ui/select.tsx","./src/components/ui/separator.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/switch.tsx","./src/components/ui/tabs.tsx","./src/components/ui/textarea.tsx","./src/components/ui/tooltip.tsx","./src/hooks/use-auto-scroll.ts","./src/hooks/use-clipboard.ts","./src/hooks/use-debounce.ts","./src/hooks/use-debounced-callback.ts","./src/hooks/use-deferred-loading.ts","./src/hooks/use-media-query.ts","./src/hooks/use-min-loading.ts","./src/hooks/use-pagination.ts","./src/hooks/use-ws-call.ts","./src/hooks/use-ws-event.ts","./src/hooks/use-ws.ts","./src/lib/constants.ts","./src/lib/format.ts","./src/lib/session-key.ts","./src/lib/slug.ts","./src/lib/utils.ts","./src/pages/agents/agent-card.tsx","./src/pages/agents/agent-create-dialog.tsx","./src/pages/agents/agent-presets.ts","./src/pages/agents/agents-page.tsx","./src/pages/agents/summoning-modal.tsx","./src/pages/agents/agent-detail/agent-config-tab.tsx","./src/pages/agents/agent-detail/agent-detail-page.tsx","./src/pages/agents/agent-detail/agent-files-tab.tsx","./src/pages/agents/agent-detail/agent-general-tab.tsx","./src/pages/agents/agent-detail/agent-shares-tab.tsx","./src/pages/agents/agent-detail/config-sections/compaction-section.tsx","./src/pages/agents/agent-detail/config-sections/config-section.tsx","./src/pages/agents/agent-detail/config-sections/context-pruning-section.tsx","./src/pages/agents/agent-detail/config-sections/index.ts","./src/pages/agents/agent-detail/config-sections/memory-section.tsx","./src/pages/agents/agent-detail/config-sections/other-config-section.tsx","./src/pages/agents/agent-detail/config-sections/sandbox-section.tsx","./src/pages/agents/agent-detail/config-sections/subagents-section.tsx","./src/pages/agents/agent-detail/config-sections/tool-policy-section.tsx","./src/pages/agents/hooks/use-agent-detail.ts","./src/pages/agents/hooks/use-agents.ts","./src/pages/approvals/approvals-page.tsx","./src/pages/approvals/hooks/use-approvals.ts","./src/pages/channels/channel-fields.tsx","./src/pages/channels/channel-instance-form-dialog.tsx","./src/pages/channels/channel-schemas.ts","./src/pages/channels/channels-page.tsx","./src/pages/channels/channels-status-view.tsx","./src/pages/channels/hooks/use-channel-instances.ts","./src/pages/channels/hooks/use-channels.ts","./src/pages/chat/chat-page.tsx","./src/pages/chat/chat-sidebar.tsx","./src/pages/chat/chat-thread.tsx","./src/pages/chat/hooks/use-chat-messages.ts","./src/pages/chat/hooks/use-chat-send.ts","./src/pages/chat/hooks/use-chat-sessions.ts","./src/pages/config/config-page.tsx","./src/pages/config/hooks/use-config.ts","./src/pages/config/sections/agents-defaults-section.tsx","./src/pages/config/sections/bindings-section.tsx","./src/pages/config/sections/channels-section.tsx","./src/pages/config/sections/cron-section.tsx","./src/pages/config/sections/gateway-section.tsx","./src/pages/config/sections/providers-section.tsx","./src/pages/config/sections/sessions-section.tsx","./src/pages/config/sections/telemetry-section.tsx","./src/pages/config/sections/tools-section.tsx","./src/pages/config/sections/tts-section.tsx","./src/pages/cron/cron-form-dialog.tsx","./src/pages/cron/cron-page.tsx","./src/pages/cron/cron-run-log-dialog.tsx","./src/pages/cron/hooks/use-cron.ts","./src/pages/custom-tools/custom-tool-form-dialog.tsx","./src/pages/custom-tools/custom-tools-page.tsx","./src/pages/custom-tools/hooks/use-custom-tools.ts","./src/pages/login/login-layout.tsx","./src/pages/login/login-page.tsx","./src/pages/login/login-tabs.tsx","./src/pages/login/pairing-form.tsx","./src/pages/login/token-form.tsx","./src/pages/logs/logs-page.tsx","./src/pages/logs/hooks/use-logs.ts","./src/pages/mcp/mcp-form-dialog.tsx","./src/pages/mcp/mcp-grants-dialog.tsx","./src/pages/mcp/mcp-page.tsx","./src/pages/mcp/hooks/use-mcp.ts","./src/pages/nodes/nodes-page.tsx","./src/pages/nodes/hooks/use-nodes.ts","./src/pages/overview/overview-page.tsx","./src/pages/providers/provider-form-dialog.tsx","./src/pages/providers/providers-page.tsx","./src/pages/providers/hooks/use-provider-models.ts","./src/pages/providers/hooks/use-providers.ts","./src/pages/sessions/session-detail-page.tsx","./src/pages/sessions/sessions-page.tsx","./src/pages/sessions/hooks/use-sessions.ts","./src/pages/skills/skill-detail-dialog.tsx","./src/pages/skills/skill-upload-dialog.tsx","./src/pages/skills/skills-page.tsx","./src/pages/skills/hooks/use-skills.ts","./src/pages/traces/trace-detail-dialog.tsx","./src/pages/traces/traces-page.tsx","./src/pages/traces/hooks/use-traces.ts","./src/pages/tts/tts-page.tsx","./src/pages/tts/hooks/use-tts-config.ts","./src/pages/usage/usage-page.tsx","./src/pages/usage/hooks/use-usage.ts","./src/stores/use-auth-store.ts","./src/stores/use-ui-store.ts","./src/types/agent.ts","./src/types/chat.ts","./src/types/session.ts"],"version":"5.7.3"}
\ No newline at end of file
+{"root":["./src/app.tsx","./src/main.tsx","./src/routes.tsx","./src/vite-env.d.ts","./src/api/errors.ts","./src/api/http-client.ts","./src/api/protocol.ts","./src/api/ws-client.ts","./src/components/chat/agent-selector.tsx","./src/components/chat/chat-input.tsx","./src/components/chat/message-bubble.tsx","./src/components/chat/message-content.tsx","./src/components/chat/rich-content.tsx","./src/components/chat/session-switcher.tsx","./src/components/chat/streaming-text.tsx","./src/components/chat/thinking-indicator.tsx","./src/components/chat/tool-call-card.tsx","./src/components/layout/app-layout.tsx","./src/components/layout/connection-status.tsx","./src/components/layout/sidebar-group.tsx","./src/components/layout/sidebar-item.tsx","./src/components/layout/sidebar.tsx","./src/components/layout/topbar.tsx","./src/components/providers/app-providers.tsx","./src/components/providers/theme-provider.tsx","./src/components/providers/ws-provider.tsx","./src/components/shared/confirm-dialog.tsx","./src/components/shared/empty-state.tsx","./src/components/shared/loading-skeleton.tsx","./src/components/shared/markdown-renderer.tsx","./src/components/shared/page-header.tsx","./src/components/shared/pagination.tsx","./src/components/shared/require-auth.tsx","./src/components/shared/search-input.tsx","./src/components/shared/status-badge.tsx","./src/components/ui/alert.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/combobox.tsx","./src/components/ui/dialog.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/scroll-area.tsx","./src/components/ui/select.tsx","./src/components/ui/separator.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/switch.tsx","./src/components/ui/tabs.tsx","./src/components/ui/textarea.tsx","./src/components/ui/tooltip.tsx","./src/hooks/use-auto-scroll.ts","./src/hooks/use-clipboard.ts","./src/hooks/use-debounce.ts","./src/hooks/use-debounced-callback.ts","./src/hooks/use-deferred-loading.ts","./src/hooks/use-media-query.ts","./src/hooks/use-min-loading.ts","./src/hooks/use-pagination.ts","./src/hooks/use-ws-call.ts","./src/hooks/use-ws-event.ts","./src/hooks/use-ws.ts","./src/lib/constants.ts","./src/lib/format.ts","./src/lib/session-key.ts","./src/lib/slug.ts","./src/lib/utils.ts","./src/pages/agents/agent-card.tsx","./src/pages/agents/agent-create-dialog.tsx","./src/pages/agents/agent-presets.ts","./src/pages/agents/agents-page.tsx","./src/pages/agents/summoning-modal.tsx","./src/pages/agents/agent-detail/agent-config-tab.tsx","./src/pages/agents/agent-detail/agent-detail-page.tsx","./src/pages/agents/agent-detail/agent-files-tab.tsx","./src/pages/agents/agent-detail/agent-general-tab.tsx","./src/pages/agents/agent-detail/agent-links-tab.tsx","./src/pages/agents/agent-detail/agent-shares-tab.tsx","./src/pages/agents/agent-detail/config-sections/compaction-section.tsx","./src/pages/agents/agent-detail/config-sections/config-section.tsx","./src/pages/agents/agent-detail/config-sections/context-pruning-section.tsx","./src/pages/agents/agent-detail/config-sections/index.ts","./src/pages/agents/agent-detail/config-sections/memory-section.tsx","./src/pages/agents/agent-detail/config-sections/other-config-section.tsx","./src/pages/agents/agent-detail/config-sections/sandbox-section.tsx","./src/pages/agents/agent-detail/config-sections/subagents-section.tsx","./src/pages/agents/agent-detail/config-sections/tool-policy-section.tsx","./src/pages/agents/agent-detail/file-sections/file-editor.tsx","./src/pages/agents/agent-detail/file-sections/file-sidebar.tsx","./src/pages/agents/agent-detail/file-sections/file-utils.ts","./src/pages/agents/agent-detail/file-sections/index.ts","./src/pages/agents/agent-detail/file-sections/open-agent-empty-state.tsx","./src/pages/agents/agent-detail/file-sections/regenerate-dialog.tsx","./src/pages/agents/agent-detail/general-sections/identity-section.tsx","./src/pages/agents/agent-detail/general-sections/index.ts","./src/pages/agents/agent-detail/general-sections/llm-config-section.tsx","./src/pages/agents/agent-detail/general-sections/workspace-section.tsx","./src/pages/agents/agent-detail/link-sections/index.ts","./src/pages/agents/agent-detail/link-sections/link-create-form.tsx","./src/pages/agents/agent-detail/link-sections/link-edit-dialog.tsx","./src/pages/agents/agent-detail/link-sections/link-list.tsx","./src/pages/agents/agent-detail/link-sections/link-utils.ts","./src/pages/agents/hooks/use-agent-detail.ts","./src/pages/agents/hooks/use-agent-links.ts","./src/pages/agents/hooks/use-agents.ts","./src/pages/approvals/approvals-page.tsx","./src/pages/approvals/hooks/use-approvals.ts","./src/pages/channels/channel-fields.tsx","./src/pages/channels/channel-instance-form-dialog.tsx","./src/pages/channels/channel-schemas.ts","./src/pages/channels/channels-page.tsx","./src/pages/channels/channels-status-view.tsx","./src/pages/channels/hooks/use-channel-instances.ts","./src/pages/channels/hooks/use-channels.ts","./src/pages/chat/chat-page.tsx","./src/pages/chat/chat-sidebar.tsx","./src/pages/chat/chat-thread.tsx","./src/pages/chat/hooks/use-chat-messages.ts","./src/pages/chat/hooks/use-chat-send.ts","./src/pages/chat/hooks/use-chat-sessions.ts","./src/pages/config/config-page.tsx","./src/pages/config/hooks/use-config.ts","./src/pages/config/sections/agents-defaults-section.tsx","./src/pages/config/sections/bindings-section.tsx","./src/pages/config/sections/channels-section.tsx","./src/pages/config/sections/cron-section.tsx","./src/pages/config/sections/gateway-section.tsx","./src/pages/config/sections/providers-section.tsx","./src/pages/config/sections/sessions-section.tsx","./src/pages/config/sections/telemetry-section.tsx","./src/pages/config/sections/tools-section.tsx","./src/pages/config/sections/tts-section.tsx","./src/pages/cron/cron-form-dialog.tsx","./src/pages/cron/cron-page.tsx","./src/pages/cron/cron-run-log-dialog.tsx","./src/pages/cron/hooks/use-cron.ts","./src/pages/custom-tools/custom-tool-form-dialog.tsx","./src/pages/custom-tools/custom-tools-page.tsx","./src/pages/custom-tools/hooks/use-custom-tools.ts","./src/pages/login/login-layout.tsx","./src/pages/login/login-page.tsx","./src/pages/login/login-tabs.tsx","./src/pages/login/pairing-form.tsx","./src/pages/login/token-form.tsx","./src/pages/logs/logs-page.tsx","./src/pages/logs/hooks/use-logs.ts","./src/pages/mcp/mcp-form-dialog.tsx","./src/pages/mcp/mcp-grants-dialog.tsx","./src/pages/mcp/mcp-page.tsx","./src/pages/mcp/hooks/use-mcp.ts","./src/pages/nodes/nodes-page.tsx","./src/pages/nodes/hooks/use-nodes.ts","./src/pages/overview/overview-page.tsx","./src/pages/providers/provider-form-dialog.tsx","./src/pages/providers/providers-page.tsx","./src/pages/providers/hooks/use-provider-models.ts","./src/pages/providers/hooks/use-provider-verify.ts","./src/pages/providers/hooks/use-providers.ts","./src/pages/sessions/session-detail-page.tsx","./src/pages/sessions/sessions-page.tsx","./src/pages/sessions/hooks/use-sessions.ts","./src/pages/skills/skill-detail-dialog.tsx","./src/pages/skills/skill-upload-dialog.tsx","./src/pages/skills/skills-page.tsx","./src/pages/skills/hooks/use-skills.ts","./src/pages/teams/team-card.tsx","./src/pages/teams/team-create-dialog.tsx","./src/pages/teams/team-detail-page.tsx","./src/pages/teams/team-members-tab.tsx","./src/pages/teams/team-tasks-tab.tsx","./src/pages/teams/teams-page.tsx","./src/pages/teams/hooks/use-teams.ts","./src/pages/teams/member-sections/index.ts","./src/pages/teams/member-sections/member-list.tsx","./src/pages/teams/member-sections/member-utils.ts","./src/pages/teams/task-sections/index.ts","./src/pages/teams/task-sections/task-list.tsx","./src/pages/teams/task-sections/task-utils.ts","./src/pages/traces/trace-detail-dialog.tsx","./src/pages/traces/traces-page.tsx","./src/pages/traces/hooks/use-traces.ts","./src/pages/tts/tts-page.tsx","./src/pages/tts/hooks/use-tts-config.ts","./src/pages/usage/usage-page.tsx","./src/pages/usage/hooks/use-usage.ts","./src/stores/use-auth-store.ts","./src/stores/use-ui-store.ts","./src/types/agent.ts","./src/types/chat.ts","./src/types/session.ts","./src/types/team.ts"],"version":"5.7.3"}
\ No newline at end of file