feat: introduce InfoLabel, ToolNameSelect, ProviderModelSelect, TagInput, and KeyValueEditor components and integrate them into various configuration pages for enhanced user experience.

This commit is contained in:
viettranx committed 2026-02-27 18:22:07 +07:00
1 parent 0105bab2d5
commit 444169860c
23 files changed
+782 -205

No files matched your search

@@ -0,0 +1,27 @@
import { Info } from "lucide-react";
import { Label } from "@/components/ui/label";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
/** Label with an (i) tooltip icon for field descriptions. */
export function InfoLabel({ children, tip }: { children: React.ReactNode; tip: string }) {
return (
<div className="flex items-center gap-1.5">
<Label>{children}</Label>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-3.5 w-3.5 shrink-0 cursor-help text-muted-foreground" />
</TooltipTrigger>
<TooltipContent side="top" className="max-w-64">
{tip}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
);
}
@@ -0,0 +1,106 @@
import { useState, useEffect, useRef } from "react";
import { Plus, Trash2 } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
interface KeyValuePair {
key: string;
value: string;
}
interface KeyValueEditorProps {
value: Record<string, string>;
onChange: (value: Record<string, string>) => void;
keyPlaceholder?: string;
valuePlaceholder?: string;
addLabel?: string;
}
function toEntries(obj: Record<string, string>): KeyValuePair[] {
const entries = Object.entries(obj).map(([key, value]) => ({ key, value }));
return entries.length > 0 ? entries : [{ key: "", value: "" }];
}
function toObject(entries: KeyValuePair[]): Record<string, string> {
const result: Record<string, string> = {};
for (const { key, value } of entries) {
if (key.trim()) {
result[key.trim()] = value;
}
}
return result;
}
export function KeyValueEditor({
value,
onChange,
keyPlaceholder = "Key",
valuePlaceholder = "Value",
addLabel = "Add",
}: KeyValueEditorProps) {
const [entries, setEntries] = useState<KeyValuePair[]>(() => toEntries(value));
const internalChange = useRef(false);
// Sync from external value changes only (not our own onChange calls)
useEffect(() => {
if (internalChange.current) {
internalChange.current = false;
return;
}
setEntries(toEntries(value));
}, [value]);
const emitChange = (next: KeyValuePair[]) => {
internalChange.current = true;
setEntries(next);
onChange(toObject(next));
};
const updateEntry = (idx: number, patch: Partial<KeyValuePair>) => {
const next = entries.map((e, i) => (i === idx ? { ...e, ...patch } : e));
emitChange(next);
};
const addEntry = () => {
// Don't emit onChange — just add an empty row locally
setEntries((prev) => [...prev, { key: "", value: "" }]);
};
const removeEntry = (idx: number) => {
const next = entries.filter((_, i) => i !== idx);
const result = next.length > 0 ? next : [{ key: "", value: "" }];
emitChange(result);
};
return (
<div className="space-y-2">
{entries.map((entry, idx) => (
<div key={idx} className="flex items-center gap-2">
<Input
value={entry.key}
onChange={(e) => updateEntry(idx, { key: e.target.value })}
placeholder={keyPlaceholder}
className="flex-1 font-mono text-sm"
/>
<Input
value={entry.value}
onChange={(e) => updateEntry(idx, { value: e.target.value })}
placeholder={valuePlaceholder}
className="flex-1 font-mono text-sm"
/>
<Button
variant="ghost"
size="icon"
className="h-9 w-9 shrink-0"
onClick={() => removeEntry(idx)}
>
<Trash2 className="h-3.5 w-3.5 text-muted-foreground" />
</Button>
</div>
))}
<Button variant="outline" size="sm" onClick={addEntry} className="gap-1.5">
<Plus className="h-3.5 w-3.5" /> {addLabel}
</Button>
</div>
);
}
@@ -0,0 +1,128 @@
import { useMemo } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
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";
import { InfoLabel } from "./info-label";
interface ProviderModelSelectProps {
provider: string;
onProviderChange: (v: string) => void;
model: string;
onModelChange: (v: string) => void;
providerTip?: string;
modelTip?: string;
providerLabel?: string;
modelLabel?: string;
providerPlaceholder?: string;
modelPlaceholder?: string;
/** Show a "Check" verify button. */
showVerify?: boolean;
}
export function ProviderModelSelect({
provider,
onProviderChange,
model,
onModelChange,
providerTip = "LLM provider name. Must match a configured provider.",
modelTip = "Model ID to use.",
providerLabel = "Provider",
modelLabel = "Model",
providerPlaceholder = "Select provider",
modelPlaceholder = "Enter or select model",
showVerify = false,
}: ProviderModelSelectProps) {
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 handleProviderChange = (v: string) => {
onProviderChange(v);
onModelChange("");
resetVerify();
};
const handleModelChange = (v: string) => {
onModelChange(v);
resetVerify();
};
const handleVerify = async () => {
if (!selectedProviderId || !model.trim()) return;
await verify(selectedProviderId, model.trim());
};
return (
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-1.5">
<InfoLabel tip={providerTip}>{providerLabel}</InfoLabel>
{enabledProviders.length > 0 ? (
<Select value={provider} onValueChange={handleProviderChange}>
<SelectTrigger>
<SelectValue placeholder={providerPlaceholder} />
</SelectTrigger>
<SelectContent>
{enabledProviders.map((p) => (
<SelectItem key={p.name} value={p.name}>
{p.display_name || p.name}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
value={provider}
onChange={(e) => handleProviderChange(e.target.value)}
placeholder="No providers configured"
/>
)}
</div>
<div className="grid gap-1.5">
<InfoLabel tip={modelTip}>{modelLabel}</InfoLabel>
<div className="flex gap-2">
<div className="flex-1">
<Combobox
value={model}
onChange={handleModelChange}
options={models.map((m) => ({ value: m.id, label: m.name }))}
placeholder={modelsLoading ? "Loading models..." : modelPlaceholder}
/>
</div>
{showVerify && (
<Button
type="button"
variant="outline"
size="sm"
className="h-9 shrink-0 px-3"
disabled={!selectedProviderId || !model.trim() || verifying}
onClick={handleVerify}
>
{verifying ? "..." : "Check"}
</Button>
)}
</div>
{showVerify && verifyResult && (
<p className={`text-xs ${verifyResult.valid ? "text-success" : "text-destructive"}`}>
{verifyResult.valid ? "Model verified" : verifyResult.error || "Verification failed"}
</p>
)}
</div>
</div>
);
}
@@ -0,0 +1,78 @@
import { useState, useRef } from "react";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
interface TagInputProps {
value: string[];
onChange: (value: string[]) => void;
placeholder?: string;
className?: string;
}
export function TagInput({
value,
onChange,
placeholder = "Type and press Enter...",
className,
}: TagInputProps) {
const [input, setInput] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
const addTag = (tag: string) => {
const trimmed = tag.trim();
if (trimmed && !value.includes(trimmed)) {
onChange([...value, trimmed]);
}
setInput("");
};
const removeTag = (tag: string) => {
onChange(value.filter((v) => v !== tag));
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter" || e.key === ",") {
e.preventDefault();
addTag(input);
}
if (e.key === "Backspace" && !input && value.length > 0) {
removeTag(value[value.length - 1]!);
}
};
return (
<div
className={cn(
"border-input dark:bg-input/30 flex min-h-9 flex-wrap items-center gap-1 rounded-md border bg-transparent px-2 py-1 text-sm shadow-xs transition-[color,box-shadow]",
"focus-within:border-ring focus-within:ring-ring/50 focus-within:ring-[3px]",
className,
)}
onClick={() => inputRef.current?.focus()}
>
{value.map((tag) => (
<span
key={tag}
className="bg-secondary text-secondary-foreground inline-flex items-center gap-0.5 rounded px-1.5 py-0.5 text-xs"
>
{tag}
<button
type="button"
className="hover:text-destructive ml-0.5"
onClick={(e) => { e.stopPropagation(); removeTag(tag); }}
>
<X className="h-3 w-3" />
</button>
</span>
))}
<input
ref={inputRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={() => { if (input.trim()) addTag(input); }}
placeholder={value.length === 0 ? placeholder : ""}
className="placeholder:text-muted-foreground min-w-[80px] flex-1 bg-transparent py-0.5 text-sm outline-none"
/>
</div>
);
}
@@ -0,0 +1,181 @@
import { useMemo, useState, useRef, useEffect } from "react";
import { X, ChevronDownIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { useBuiltinTools } from "@/pages/builtin-tools/hooks/use-builtin-tools";
import { useCustomTools } from "@/pages/custom-tools/hooks/use-custom-tools";
interface ToolNameSelectProps {
value: string[];
onChange: (value: string[]) => void;
placeholder?: string;
className?: string;
}
interface ToolOption {
name: string;
displayName: string;
group: "built-in" | "custom";
}
export function ToolNameSelect({
value,
onChange,
placeholder = "Select or type tool names...",
className,
}: ToolNameSelectProps) {
const { tools: builtinTools } = useBuiltinTools();
const { tools: customTools } = useCustomTools();
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const allTools = useMemo<ToolOption[]>(() => {
const builtin: ToolOption[] = builtinTools.map((t) => ({
name: t.name,
displayName: t.display_name || t.name,
group: "built-in",
}));
const custom: ToolOption[] = customTools.map((t) => ({
name: t.name,
displayName: t.name,
group: "custom",
}));
return [...builtin, ...custom];
}, [builtinTools, customTools]);
const filtered = useMemo(() => {
const q = search.toLowerCase();
return allTools
.filter((t) => !value.includes(t.name))
.filter((t) => !q || t.name.toLowerCase().includes(q) || t.displayName.toLowerCase().includes(q));
}, [allTools, value, search]);
const grouped = useMemo(() => {
const builtinGroup = filtered.filter((t) => t.group === "built-in");
const customGroup = filtered.filter((t) => t.group === "custom");
return { builtin: builtinGroup, custom: customGroup };
}, [filtered]);
// Close on outside click
useEffect(() => {
if (!open) return;
const handler = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false);
}
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, [open]);
const addTool = (name: string) => {
if (!value.includes(name)) {
onChange([...value, name]);
}
setSearch("");
inputRef.current?.focus();
};
const removeTool = (name: string) => {
onChange(value.filter((v) => v !== name));
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter" || e.key === ",") {
e.preventDefault();
const trimmed = search.trim().replace(/,$/, "");
if (trimmed) {
addTool(trimmed);
}
}
if (e.key === "Backspace" && !search && value.length > 0) {
removeTool(value[value.length - 1]!);
}
};
return (
<div ref={containerRef} className={cn("relative", className)}>
<div
className={cn(
"border-input dark:bg-input/30 flex min-h-9 flex-wrap items-center gap-1 rounded-md border bg-transparent px-2 py-1 text-sm shadow-xs transition-[color,box-shadow]",
"focus-within:border-ring focus-within:ring-ring/50 focus-within:ring-[3px]",
)}
onClick={() => inputRef.current?.focus()}
>
{value.map((name) => (
<span
key={name}
className="bg-secondary text-secondary-foreground inline-flex items-center gap-0.5 rounded px-1.5 py-0.5 text-xs"
>
{name}
<button
type="button"
className="hover:text-destructive ml-0.5"
onClick={(e) => { e.stopPropagation(); removeTool(name); }}
>
<X className="h-3 w-3" />
</button>
</span>
))}
<input
ref={inputRef}
value={search}
onChange={(e) => {
setSearch(e.target.value);
if (!open) setOpen(true);
}}
onFocus={() => setOpen(true)}
onKeyDown={handleKeyDown}
placeholder={value.length === 0 ? placeholder : ""}
className="placeholder:text-muted-foreground min-w-[80px] flex-1 bg-transparent py-0.5 text-sm outline-none"
/>
<ChevronDownIcon
className="text-muted-foreground size-4 shrink-0 cursor-pointer opacity-50"
onClick={() => setOpen(!open)}
/>
</div>
{open && (grouped.builtin.length > 0 || grouped.custom.length > 0) && (
<div className="bg-popover text-popover-foreground absolute top-full left-0 z-50 mt-1 max-h-60 w-full overflow-y-auto rounded-md border p-1 shadow-md">
{grouped.builtin.length > 0 && (
<>
<div className="text-muted-foreground px-2 py-1 text-[10px] font-semibold uppercase tracking-wider">
Built-in
</div>
{grouped.builtin.map((t) => (
<button
key={t.name}
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={() => addTool(t.name)}
className="hover:bg-accent hover:text-accent-foreground flex w-full cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none"
>
<span className="truncate">{t.displayName}</span>
<code className="text-muted-foreground text-[10px]">{t.name}</code>
</button>
))}
</>
)}
{grouped.custom.length > 0 && (
<>
<div className="text-muted-foreground mt-1 px-2 py-1 text-[10px] font-semibold uppercase tracking-wider">
Custom
</div>
{grouped.custom.map((t) => (
<button
key={t.name}
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={() => addTool(t.name)}
className="hover:bg-accent hover:text-accent-foreground flex w-full cursor-pointer items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none"
>
<span className="truncate">{t.name}</span>
</button>
))}
</>
)}
</div>
)}
</div>
);
}
+20 -8
View File
@@ -32,6 +32,10 @@
--sidebar-accent-foreground: oklch(0.511 0.262 276.966);
--sidebar-border: oklch(0.885 0.02 255.508);
--sidebar-ring: oklch(0.511 0.262 276.966);
--success: oklch(0.55 0.2 145);
--success-foreground: oklch(0.98 0 0);
--warning: oklch(0.75 0.18 75);
--warning-foreground: oklch(0.25 0.05 75);
--chart-1: oklch(0.585 0.233 277.117);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
@@ -48,24 +52,28 @@
--popover-foreground: oklch(0.984 0.003 247.858);
--primary: oklch(0.585 0.233 277.117);
--primary-foreground: oklch(0.985 0.002 247);
--secondary: oklch(0.279 0.041 260.031);
--secondary: oklch(0.240 0.035 260);
--secondary-foreground: oklch(0.984 0.003 247.858);
--muted: oklch(0.279 0.041 260.031);
--muted-foreground: oklch(0.704 0.04 256.788);
--accent: oklch(0.279 0.041 260.031);
--muted: oklch(0.220 0.030 260);
--muted-foreground: oklch(0.650 0.030 256);
--accent: oklch(0.300 0.055 270);
--accent-foreground: oklch(0.984 0.003 247.858);
--destructive: oklch(0.396 0.141 25.723);
--destructive-foreground: oklch(0.637 0.237 25.331);
--border: oklch(0.279 0.041 260.031);
--input: oklch(0.279 0.041 260.031);
--border: oklch(0.320 0.025 260);
--input: oklch(0.250 0.035 260);
--ring: oklch(0.585 0.233 277.117);
--success: oklch(0.45 0.15 145);
--success-foreground: oklch(0.85 0.1 145);
--warning: oklch(0.55 0.15 75);
--warning-foreground: oklch(0.85 0.1 75);
--sidebar: oklch(0.168 0.042 264.695);
--sidebar-foreground: oklch(0.984 0.003 247.858);
--sidebar-primary: oklch(0.585 0.233 277.117);
--sidebar-primary-foreground: oklch(0.985 0.002 247);
--sidebar-accent: oklch(0.279 0.041 260.031);
--sidebar-accent: oklch(0.300 0.055 270);
--sidebar-accent-foreground: oklch(0.984 0.003 247.858);
--sidebar-border: oklch(0.279 0.041 260.031);
--sidebar-border: oklch(0.320 0.025 260);
--sidebar-ring: oklch(0.585 0.233 277.117);
--chart-1: oklch(0.673 0.182 276.935);
--chart-2: oklch(0.696 0.17 162.48);
@@ -106,6 +114,10 @@
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-success: var(--success);
--color-success-foreground: var(--success-foreground);
--color-warning: var(--warning);
--color-warning-foreground: var(--warning-foreground);
--color-chart-5: var(--chart-5);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
@@ -178,7 +178,7 @@ export function AgentCreateDialog({ open, onOpenChange, onCreate }: AgentCreateD
</Button>
</div>
{verifyResult && (
<p className={`text-xs ${verifyResult.valid ? "text-emerald-400" : "text-red-400"}`}>
<p className={`text-xs ${verifyResult.valid ? "text-success" : "text-destructive"}`}>
{verifyResult.valid ? "Model verified" : verifyResult.error || "Verification failed"}
</p>
)}
@@ -99,7 +99,7 @@ export function AgentConfigTab({ agent, onUpdate }: AgentConfigTabProps) {
};
return (
<div className="max-w-2xl space-y-6">
<div className="max-w-4xl space-y-6">
<SubagentsSection
enabled={subEnabled}
value={sub}
@@ -164,7 +164,7 @@ export function AgentConfigTab({ agent, onUpdate }: AgentConfigTabProps) {
)}
<div className="flex items-center justify-end gap-2 pt-2">
{saved && (
<span className="flex items-center gap-1 text-sm text-green-600">
<span className="flex items-center gap-1 text-sm text-success">
<Check className="h-3.5 w-3.5" /> Saved
</span>
)}
@@ -62,7 +62,7 @@ export function AgentGeneralTab({ agent, onUpdate }: AgentGeneralTabProps) {
};
return (
<div className="max-w-2xl space-y-6">
<div className="max-w-4xl space-y-6">
<IdentitySection
agentKey={agent.agent_key}
displayName={displayName}
@@ -108,7 +108,7 @@ export function AgentGeneralTab({ agent, onUpdate }: AgentGeneralTabProps) {
)}
<div className="flex items-center justify-end gap-2">
{saved && (
<span className="flex items-center gap-1 text-sm text-green-600">
<span className="flex items-center gap-1 text-sm text-success">
<Check className="h-3.5 w-3.5" /> Saved
</span>
)}
@@ -1,31 +1,6 @@
import { Info } from "lucide-react";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
/** Label with an (i) tooltip icon for field descriptions. */
export function InfoLabel({ children, tip }: { children: React.ReactNode; tip: string }) {
return (
<div className="flex items-center gap-1.5">
<Label>{children}</Label>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-3.5 w-3.5 shrink-0 cursor-help text-muted-foreground" />
</TooltipTrigger>
<TooltipContent side="top" className="max-w-64">
{tip}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
);
}
export { InfoLabel } from "@/components/shared/info-label";
interface ConfigSectionProps {
title: string;
@@ -2,6 +2,7 @@ import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import type { MemoryConfig } from "@/types/agent";
import { ConfigSection, InfoLabel, numOrUndef } from "./config-section";
import { ProviderModelSelect } from "@/components/shared/provider-model-select";
interface MemorySectionProps {
enabled: boolean;
@@ -25,24 +26,18 @@ export function MemorySection({ enabled, value, onToggle, onChange }: MemorySect
/>
<InfoLabel tip="Enable or disable the memory system for this agent. When enabled, the agent can store and recall information across sessions.">Enabled</InfoLabel>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<InfoLabel tip="LLM provider used for generating text embeddings. Leave empty to auto-detect from the agent's main provider.">Embedding Provider</InfoLabel>
<Input
placeholder="(auto)"
value={value.embedding_provider ?? ""}
onChange={(e) => onChange({ ...value, embedding_provider: e.target.value || undefined })}
/>
</div>
<div className="space-y-2">
<InfoLabel tip="Embedding model name. Must be supported by the embedding provider (e.g. text-embedding-3-small for OpenAI).">Embedding Model</InfoLabel>
<Input
placeholder="text-embedding-3-small"
value={value.embedding_model ?? ""}
onChange={(e) => onChange({ ...value, embedding_model: e.target.value || undefined })}
/>
</div>
</div>
<ProviderModelSelect
provider={value.embedding_provider ?? ""}
onProviderChange={(v) => onChange({ ...value, embedding_provider: v || undefined })}
model={value.embedding_model ?? ""}
onModelChange={(v) => onChange({ ...value, embedding_model: v || undefined })}
providerLabel="Embedding Provider"
modelLabel="Embedding Model"
providerTip="LLM provider used for generating text embeddings. Leave empty to auto-detect from the agent's main provider."
modelTip="Embedding model name (e.g. text-embedding-3-small). Must be supported by the provider."
providerPlaceholder="(auto)"
modelPlaceholder="text-embedding-3-small"
/>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<InfoLabel tip="Maximum number of memory entries returned per search query.">Max Results</InfoLabel>
@@ -1,4 +1,6 @@
import { useState, useEffect } from "react";
import { Textarea } from "@/components/ui/textarea";
import { Button } from "@/components/ui/button";
import { ConfigSection } from "./config-section";
interface OtherConfigSectionProps {
@@ -9,19 +11,46 @@ interface OtherConfigSectionProps {
}
export function OtherConfigSection({ enabled, value, onToggle, onChange }: OtherConfigSectionProps) {
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="Other Config"
description="Generic JSON config (no schema)"
description="Additional JSON config for advanced settings not covered by other sections"
enabled={enabled}
onToggle={onToggle}
>
<Textarea
className="font-mono text-sm"
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">
Format JSON
</Button>
{!validJson && <span className="text-xs text-destructive">Invalid JSON syntax</span>}
</div>
</ConfigSection>
);
}
@@ -1,4 +1,6 @@
import { useMemo } from "react";
import { Input } from "@/components/ui/input";
import { Combobox } from "@/components/ui/combobox";
import {
Select,
SelectContent,
@@ -8,6 +10,8 @@ import {
} from "@/components/ui/select";
import type { SubagentsConfig } from "@/types/agent";
import { ConfigSection, InfoLabel, numOrUndef } from "./config-section";
import { useProviders } from "@/pages/providers/hooks/use-providers";
import { useProviderModels } from "@/pages/providers/hooks/use-provider-models";
interface SubagentsSectionProps {
enabled: boolean;
@@ -17,6 +21,15 @@ interface SubagentsSectionProps {
}
export function SubagentsSection({ enabled, value, onToggle, onChange }: SubagentsSectionProps) {
const { providers } = useProviders();
const enabledProviders = providers.filter((p) => p.enabled);
// Use the first enabled provider for model list suggestions
const defaultProviderId = useMemo(
() => enabledProviders[0]?.id,
[enabledProviders],
);
const { models, loading: modelsLoading } = useProviderModels(defaultProviderId);
return (
<ConfigSection
title="Subagents"
@@ -71,10 +84,11 @@ export function SubagentsSection({ enabled, value, onToggle, onChange }: Subagen
</div>
<div className="space-y-2">
<InfoLabel tip="LLM model for sub-agents. Leave empty to inherit the parent agent's model.">Model Override</InfoLabel>
<Input
placeholder="(inherit from agent)"
<Combobox
value={value.model ?? ""}
onChange={(e) => onChange({ ...value, model: e.target.value || undefined })}
onChange={(v) => onChange({ ...value, model: v || undefined })}
options={models.map((m) => ({ value: m.id, label: m.name }))}
placeholder={modelsLoading ? "Loading models..." : "(inherit from agent)"}
/>
</div>
</ConfigSection>
@@ -1,4 +1,3 @@
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
@@ -7,7 +6,8 @@ import {
SelectValue,
} from "@/components/ui/select";
import type { ToolPolicyConfig } from "@/types/agent";
import { ConfigSection, InfoLabel, arrayToTags, tagsToArray } from "./config-section";
import { ConfigSection, InfoLabel } from "./config-section";
import { ToolNameSelect } from "@/components/shared/tool-name-select";
interface ToolPolicySectionProps {
enabled: boolean;
@@ -39,27 +39,27 @@ export function ToolPolicySection({ enabled, value, onToggle, onChange }: ToolPo
</Select>
</div>
<div className="space-y-2">
<InfoLabel tip="Comma-separated allowlist. Only these tools will be available (overrides profile). Leave empty to use profile defaults.">Allow</InfoLabel>
<Input
placeholder="tool1, tool2, ..."
value={arrayToTags(value.allow)}
onChange={(e) => onChange({ ...value, allow: tagsToArray(e.target.value) })}
<InfoLabel tip="Explicit allowlist. Only these tools will be available (overrides profile). Leave empty to use profile defaults.">Allow</InfoLabel>
<ToolNameSelect
value={value.allow ?? []}
onChange={(v) => onChange({ ...value, allow: v.length > 0 ? v : undefined })}
placeholder="Select tools to allow..."
/>
</div>
<div className="space-y-2">
<InfoLabel tip="Comma-separated denylist. These tools will be blocked even if allowed by the profile.">Deny</InfoLabel>
<Input
placeholder="tool1, tool2, ..."
value={arrayToTags(value.deny)}
onChange={(e) => onChange({ ...value, deny: tagsToArray(e.target.value) })}
<InfoLabel tip="Denylist. These tools will be blocked even if allowed by the profile.">Deny</InfoLabel>
<ToolNameSelect
value={value.deny ?? []}
onChange={(v) => onChange({ ...value, deny: v.length > 0 ? v : undefined })}
placeholder="Select tools to deny..."
/>
</div>
<div className="space-y-2">
<InfoLabel tip="Additional tools added on top of the profile defaults. Useful for enabling optional tools like web_fetch without overriding the whole profile.">Also Allow</InfoLabel>
<Input
placeholder="web_fetch, web_search, ..."
value={arrayToTags(value.alsoAllow)}
onChange={(e) => onChange({ ...value, alsoAllow: tagsToArray(e.target.value) })}
<InfoLabel tip="Additional tools on top of profile defaults. Useful for enabling optional tools without overriding the whole profile.">Also Allow</InfoLabel>
<ToolNameSelect
value={value.alsoAllow ?? []}
onChange={(v) => onChange({ ...value, alsoAllow: v.length > 0 ? v : undefined })}
placeholder="Select additional tools..."
/>
</div>
</ConfigSection>
@@ -127,7 +127,7 @@ export function LlmConfigSection({
)}
</div>
{verifyResult && (
<p className={`text-xs ${verifyResult.valid ? "text-emerald-400" : "text-red-400"}`}>
<p className={`text-xs ${verifyResult.valid ? "text-success" : "text-destructive"}`}>
{verifyResult.valid ? "Model verified" : verifyResult.error || "Verification failed"}
</p>
)}
@@ -163,7 +163,7 @@ function MediaSettingsForm({
</Button>
</div>
{verifyResult && (
<p className={`text-xs ${verifyResult.valid ? "text-emerald-500" : "text-red-500"}`}>
<p className={`text-xs ${verifyResult.valid ? "text-success" : "text-destructive"}`}>
{verifyResult.valid ? "Model verified" : verifyResult.error || "Verification failed"}
</p>
)}
@@ -193,14 +193,38 @@ function JsonSettingsForm({
const [json, setJson] = useState("");
const [error, setError] = useState("");
const [saving, setSaving] = useState(false);
const [validJson, setValidJson] = useState(true);
useEffect(() => {
if (tool) {
setJson(JSON.stringify(tool.settings ?? {}, null, 2));
setError("");
setValidJson(true);
}
}, [tool]);
const handleJsonChange = (text: string) => {
setJson(text);
try {
JSON.parse(text);
setValidJson(true);
setError("");
} catch {
setValidJson(false);
}
};
const handleFormat = () => {
try {
const parsed = JSON.parse(json);
setJson(JSON.stringify(parsed, null, 2));
setError("");
setValidJson(true);
} catch {
setError("Cannot format: invalid JSON");
}
};
const handleSave = async () => {
if (!tool) return;
try {
@@ -220,21 +244,30 @@ function JsonSettingsForm({
<>
<DialogHeader>
<DialogTitle>Settings: {tool?.display_name ?? tool?.name}</DialogTitle>
<DialogDescription>
Edit tool-specific settings as JSON. Changes take effect immediately after saving.
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<Textarea
value={json}
onChange={(e) => setJson(e.target.value)}
onChange={(e) => handleJsonChange(e.target.value)}
rows={10}
className="font-mono text-sm"
className={`font-mono text-sm ${!validJson ? "border-destructive" : ""}`}
/>
<div className="flex items-center justify-between">
<Button variant="ghost" size="sm" onClick={handleFormat} className="h-7 px-2 text-xs">
Format JSON
</Button>
{!validJson && <span className="text-xs text-destructive">Invalid JSON syntax</span>}
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={handleSave} disabled={saving}>
<Button onClick={handleSave} disabled={saving || !validJson}>
{saving ? "Saving..." : "Save"}
</Button>
</DialogFooter>
@@ -6,6 +6,8 @@ import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { InfoLabel } from "@/components/shared/info-label";
import { ProviderModelSelect } from "@/components/shared/provider-model-select";
/* eslint-disable @typescript-eslint/no-explicit-any */
type AgentsData = Record<string, any>;
@@ -72,28 +74,19 @@ export function AgentsDefaultsSection({ data, onSave, saving }: Props) {
</CardHeader>
<CardContent className="space-y-4">
{/* Core fields */}
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-1.5">
<Label>Provider</Label>
<Input
value={defaults.provider ?? ""}
onChange={(e) => updateDefaults({ provider: e.target.value })}
placeholder="anthropic"
/>
</div>
<div className="grid gap-1.5">
<Label>Model</Label>
<Input
value={defaults.model ?? ""}
onChange={(e) => updateDefaults({ model: e.target.value })}
placeholder="claude-sonnet-4-5-20250929"
/>
</div>
</div>
<ProviderModelSelect
provider={defaults.provider ?? ""}
onProviderChange={(v) => updateDefaults({ provider: v })}
model={defaults.model ?? ""}
onModelChange={(v) => updateDefaults({ model: v })}
providerTip="Default LLM provider for all agents. Must match a configured provider name."
modelTip="Default model ID for all agents. Agents can override this in their own settings."
showVerify
/>
<div className="grid grid-cols-4 gap-4">
<div className="grid gap-1.5">
<Label>Max Tokens</Label>
<InfoLabel tip="Maximum output tokens per LLM response. Higher values allow longer responses.">Max Tokens</InfoLabel>
<Input
type="number"
value={defaults.max_tokens ?? ""}
@@ -102,7 +95,7 @@ export function AgentsDefaultsSection({ data, onSave, saving }: Props) {
/>
</div>
<div className="grid gap-1.5">
<Label>Temperature</Label>
<InfoLabel tip="Sampling temperature (0-2). Lower = more deterministic, higher = more creative.">Temperature</InfoLabel>
<Input
type="number"
step="0.1"
@@ -114,7 +107,7 @@ export function AgentsDefaultsSection({ data, onSave, saving }: Props) {
/>
</div>
<div className="grid gap-1.5">
<Label>Max Tool Iterations</Label>
<InfoLabel tip="Maximum number of tool calls per agent request before stopping.">Max Tool Iterations</InfoLabel>
<Input
type="number"
value={defaults.max_tool_iterations ?? ""}
@@ -123,7 +116,7 @@ export function AgentsDefaultsSection({ data, onSave, saving }: Props) {
/>
</div>
<div className="grid gap-1.5">
<Label>Context Window</Label>
<InfoLabel tip="Maximum context size in tokens. Should match the model's context window limit.">Context Window</InfoLabel>
<Input
type="number"
value={defaults.context_window ?? ""}
@@ -135,7 +128,7 @@ export function AgentsDefaultsSection({ data, onSave, saving }: Props) {
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-1.5">
<Label>Workspace</Label>
<InfoLabel tip="Default filesystem workspace path for agent file operations.">Workspace</InfoLabel>
<Input
value={defaults.workspace ?? ""}
onChange={(e) => updateDefaults({ workspace: e.target.value })}
@@ -143,7 +136,7 @@ export function AgentsDefaultsSection({ data, onSave, saving }: Props) {
/>
</div>
<div className="flex items-center justify-between">
<Label>Restrict to Workspace</Label>
<InfoLabel tip="When enabled, agents can only read/write files within the workspace directory.">Restrict to Workspace</InfoLabel>
<Switch
checked={defaults.restrict_to_workspace ?? false}
onCheckedChange={(v) => updateDefaults({ restrict_to_workspace: v })}
@@ -159,12 +152,12 @@ export function AgentsDefaultsSection({ data, onSave, saving }: Props) {
onToggle={() => toggleSub("subagents")}
>
<div className="grid grid-cols-2 gap-4">
<Field label="Max Concurrent" type="number" value={subagents.maxConcurrent} onChange={(v) => updateNested("subagents", { maxConcurrent: Number(v) })} placeholder="20" />
<Field label="Max Spawn Depth" type="number" value={subagents.maxSpawnDepth} onChange={(v) => updateNested("subagents", { maxSpawnDepth: Number(v) })} placeholder="1" />
<Field label="Max Children/Agent" type="number" value={subagents.maxChildrenPerAgent} onChange={(v) => updateNested("subagents", { maxChildrenPerAgent: Number(v) })} placeholder="5" />
<Field label="Archive After (min)" type="number" value={subagents.archiveAfterMinutes} onChange={(v) => updateNested("subagents", { archiveAfterMinutes: Number(v) })} placeholder="60" />
<Field label="Max Concurrent" tip="Maximum subagents running at the same time." type="number" value={subagents.maxConcurrent} onChange={(v) => updateNested("subagents", { maxConcurrent: Number(v) })} placeholder="20" />
<Field label="Max Spawn Depth" tip="Maximum nesting depth for subagent spawning (agent → subagent → sub-subagent)." type="number" value={subagents.maxSpawnDepth} onChange={(v) => updateNested("subagents", { maxSpawnDepth: Number(v) })} placeholder="1" />
<Field label="Max Children/Agent" tip="Maximum child subagents a single agent can spawn." type="number" value={subagents.maxChildrenPerAgent} onChange={(v) => updateNested("subagents", { maxChildrenPerAgent: Number(v) })} placeholder="5" />
<Field label="Archive After (min)" tip="Auto-archive idle subagent sessions after this many minutes." type="number" value={subagents.archiveAfterMinutes} onChange={(v) => updateNested("subagents", { archiveAfterMinutes: Number(v) })} placeholder="60" />
</div>
<Field label="Model Override" value={subagents.model} onChange={(v) => updateNested("subagents", { model: v })} placeholder="Use default" />
<Field label="Model Override" tip="Use a different model for subagents. Leave empty to inherit the parent agent's model." value={subagents.model} onChange={(v) => updateNested("subagents", { model: v })} placeholder="Use default" />
</SubSection>
<SubSection
@@ -177,11 +170,21 @@ export function AgentsDefaultsSection({ data, onSave, saving }: Props) {
<Label>Enabled</Label>
<Switch checked={memory.enabled !== false} onCheckedChange={(v) => updateNested("memory", { enabled: v })} />
</div>
<ProviderModelSelect
provider={memory.embedding_provider ?? ""}
onProviderChange={(v) => updateNested("memory", { embedding_provider: v || undefined })}
model={memory.embedding_model ?? ""}
onModelChange={(v) => updateNested("memory", { embedding_model: v || undefined })}
providerLabel="Embedding Provider"
modelLabel="Embedding Model"
providerTip="Provider for generating text embeddings. Auto-detects from agent's main provider if empty."
modelTip="Embedding model name (e.g. text-embedding-3-small). Must be supported by the provider."
providerPlaceholder="(auto)"
modelPlaceholder="text-embedding-3-small"
/>
<div className="grid grid-cols-2 gap-4">
<Field label="Embedding Provider" value={memory.embedding_provider} onChange={(v) => updateNested("memory", { embedding_provider: v })} placeholder="auto" />
<Field label="Embedding Model" value={memory.embedding_model} onChange={(v) => updateNested("memory", { embedding_model: v })} placeholder="text-embedding-3-small" />
<Field label="Max Results" type="number" value={memory.max_results} onChange={(v) => updateNested("memory", { max_results: Number(v) })} placeholder="6" />
<Field label="Min Score" type="number" step="0.01" value={memory.min_score} onChange={(v) => updateNested("memory", { min_score: Number(v) })} placeholder="0.35" />
<Field label="Max Results" tip="Maximum memory entries returned per search query." type="number" value={memory.max_results} onChange={(v) => updateNested("memory", { max_results: Number(v) })} placeholder="6" />
<Field label="Min Score" tip="Minimum similarity score (0-1) for memory search results. Lower = more results but less relevant." type="number" step="0.01" value={memory.min_score} onChange={(v) => updateNested("memory", { min_score: Number(v) })} placeholder="0.35" />
</div>
</SubSection>
@@ -192,8 +195,8 @@ export function AgentsDefaultsSection({ data, onSave, saving }: Props) {
onToggle={() => toggleSub("compaction")}
>
<div className="grid grid-cols-2 gap-4">
<Field label="Reserve Tokens Floor" type="number" value={compaction.reserveTokensFloor} onChange={(v) => updateNested("compaction", { reserveTokensFloor: Number(v) })} placeholder="20000" />
<Field label="Max History Share" type="number" step="0.05" value={compaction.maxHistoryShare} onChange={(v) => updateNested("compaction", { maxHistoryShare: Number(v) })} placeholder="0.75" />
<Field label="Reserve Tokens Floor" tip="Minimum tokens to reserve for new content after compaction. Prevents over-compaction." type="number" value={compaction.reserveTokensFloor} onChange={(v) => updateNested("compaction", { reserveTokensFloor: Number(v) })} placeholder="20000" />
<Field label="Max History Share" tip="Maximum fraction (0-1) of context window that conversation history can occupy before compaction triggers." type="number" step="0.05" value={compaction.maxHistoryShare} onChange={(v) => updateNested("compaction", { maxHistoryShare: Number(v) })} placeholder="0.75" />
</div>
</SubSection>
@@ -214,7 +217,7 @@ export function AgentsDefaultsSection({ data, onSave, saving }: Props) {
</SelectContent>
</Select>
</div>
<Field label="Keep Last Assistants" type="number" value={pruning.keepLastAssistants} onChange={(v) => updateNested("contextPruning", { keepLastAssistants: Number(v) })} placeholder="3" />
<Field label="Keep Last Assistants" tip="Number of recent assistant messages to always keep when pruning context." type="number" value={pruning.keepLastAssistants} onChange={(v) => updateNested("contextPruning", { keepLastAssistants: Number(v) })} placeholder="3" />
</div>
</SubSection>
@@ -225,10 +228,10 @@ export function AgentsDefaultsSection({ data, onSave, saving }: Props) {
onToggle={() => toggleSub("heartbeat")}
>
<div className="grid grid-cols-2 gap-4">
<Field label="Every" value={heartbeat.every} onChange={(v) => updateNested("heartbeat", { every: v })} placeholder="30m (0m = disabled)" />
<Field label="Model Override" value={heartbeat.model} onChange={(v) => updateNested("heartbeat", { model: v })} placeholder="Use default" />
<Field label="Session" value={heartbeat.session} onChange={(v) => updateNested("heartbeat", { session: v })} placeholder="main" />
<Field label="Target" value={heartbeat.target} onChange={(v) => updateNested("heartbeat", { target: v })} placeholder="last" />
<Field label="Every" tip="How often the agent wakes up. Go duration format (e.g. 30m, 1h). 0m = disabled." value={heartbeat.every} onChange={(v) => updateNested("heartbeat", { every: v })} placeholder="30m (0m = disabled)" />
<Field label="Model Override" tip="Use a different model for heartbeat checks. Leave empty for default." value={heartbeat.model} onChange={(v) => updateNested("heartbeat", { model: v })} placeholder="Use default" />
<Field label="Session" tip="Session key to use for heartbeat messages." value={heartbeat.session} onChange={(v) => updateNested("heartbeat", { session: v })} placeholder="main" />
<Field label="Target" tip="Target for heartbeat. 'last' resumes the last active session." value={heartbeat.target} onChange={(v) => updateNested("heartbeat", { target: v })} placeholder="last" />
</div>
</SubSection>
@@ -250,10 +253,10 @@ export function AgentsDefaultsSection({ data, onSave, saving }: Props) {
</SelectContent>
</Select>
</div>
<Field label="Image" value={sandbox.image} onChange={(v) => updateNested("sandbox", { image: v })} placeholder="goclaw-sandbox:bookworm-slim" />
<Field label="Memory (MB)" type="number" value={sandbox.memory_mb} onChange={(v) => updateNested("sandbox", { memory_mb: Number(v) })} placeholder="512" />
<Field label="CPUs" type="number" step="0.5" value={sandbox.cpus} onChange={(v) => updateNested("sandbox", { cpus: Number(v) })} placeholder="1.0" />
<Field label="Timeout (sec)" type="number" value={sandbox.timeout_sec} onChange={(v) => updateNested("sandbox", { timeout_sec: Number(v) })} placeholder="300" />
<Field label="Image" tip="Docker image name for the sandbox container." value={sandbox.image} onChange={(v) => updateNested("sandbox", { image: v })} placeholder="goclaw-sandbox:bookworm-slim" />
<Field label="Memory (MB)" tip="Memory limit in MB for the sandbox container." type="number" value={sandbox.memory_mb} onChange={(v) => updateNested("sandbox", { memory_mb: Number(v) })} placeholder="512" />
<Field label="CPUs" tip="CPU limit for the sandbox container (fractional values allowed)." type="number" step="0.5" value={sandbox.cpus} onChange={(v) => updateNested("sandbox", { cpus: Number(v) })} placeholder="1.0" />
<Field label="Timeout (sec)" tip="Maximum execution time in seconds before the sandbox is killed." type="number" value={sandbox.timeout_sec} onChange={(v) => updateNested("sandbox", { timeout_sec: Number(v) })} placeholder="300" />
<div className="flex items-center justify-between">
<Label>Network Enabled</Label>
<Switch checked={sandbox.network_enabled ?? false} onCheckedChange={(v) => updateNested("sandbox", { network_enabled: v })} />
@@ -301,13 +304,14 @@ function SubSection({
<span className="ml-2 text-xs text-muted-foreground">{desc}</span>
</div>
</button>
{open && <div className="space-y-3 border-t px-3 py-3">{children}</div>}
{open && <div className="space-y-3 border-t px-4 py-3">{children}</div>}
</div>
);
}
function Field({
label,
tip,
value,
onChange,
placeholder,
@@ -315,6 +319,7 @@ function Field({
step,
}: {
label: string;
tip?: string;
value: any;
onChange: (v: string) => void;
placeholder?: string;
@@ -323,7 +328,7 @@ function Field({
}) {
return (
<div className="grid gap-1.5">
<Label>{label}</Label>
{tip ? <InfoLabel tip={tip}>{label}</InfoLabel> : <Label>{label}</Label>}
<Input
type={type}
step={step}
@@ -2,8 +2,8 @@ import { useState, useEffect } from "react";
import { Save } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { InfoLabel } from "@/components/shared/info-label";
interface CronData {
max_retries?: number;
@@ -44,7 +44,7 @@ export function CronSection({ data, onSave, saving }: Props) {
<CardContent className="space-y-4">
<div className="grid grid-cols-3 gap-4">
<div className="grid gap-1.5">
<Label>Max Retries</Label>
<InfoLabel tip="Maximum number of retry attempts for failed cron jobs before giving up.">Max Retries</InfoLabel>
<Input
type="number"
value={draft.max_retries ?? ""}
@@ -54,7 +54,7 @@ export function CronSection({ data, onSave, saving }: Props) {
/>
</div>
<div className="grid gap-1.5">
<Label>Base Delay</Label>
<InfoLabel tip="Initial delay between retries with exponential backoff. Go duration format (e.g. 2s, 500ms).">Base Delay</InfoLabel>
<Input
value={draft.retry_base_delay ?? ""}
onChange={(e) => update({ retry_base_delay: e.target.value })}
@@ -62,7 +62,7 @@ export function CronSection({ data, onSave, saving }: Props) {
/>
</div>
<div className="grid gap-1.5">
<Label>Max Delay</Label>
<InfoLabel tip="Maximum delay cap for exponential backoff. Retries won't wait longer than this. Go duration format.">Max Delay</InfoLabel>
<Input
value={draft.retry_max_delay ?? ""}
onChange={(e) => update({ retry_max_delay: e.target.value })}
@@ -2,9 +2,10 @@ import { useState, useEffect } from "react";
import { Save } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { InfoLabel } from "@/components/shared/info-label";
import { TagInput } from "@/components/shared/tag-input";
interface GatewayData {
host?: string;
@@ -64,7 +65,7 @@ export function GatewaySection({ data, onSave, saving }: Props) {
<CardContent className="space-y-4">
<div className="grid grid-cols-3 gap-4">
<div className="grid gap-1.5">
<Label>Host</Label>
<InfoLabel tip="IP address to bind the server. Use 0.0.0.0 to accept connections from any interface.">Host</InfoLabel>
<Input
value={draft.host ?? ""}
onChange={(e) => update({ host: e.target.value })}
@@ -72,7 +73,7 @@ export function GatewaySection({ data, onSave, saving }: Props) {
/>
</div>
<div className="grid gap-1.5">
<Label>Port</Label>
<InfoLabel tip="TCP port for the WebSocket and HTTP server.">Port</InfoLabel>
<Input
type="number"
value={draft.port ?? ""}
@@ -81,7 +82,7 @@ export function GatewaySection({ data, onSave, saving }: Props) {
/>
</div>
<div className="grid gap-1.5">
<Label>Token</Label>
<InfoLabel tip="Bearer token for authenticating WebSocket and API connections. Managed via GOCLAW_GATEWAY_TOKEN env var.">Token</InfoLabel>
<Input
type="password"
value={draft.token ?? ""}
@@ -97,26 +98,26 @@ export function GatewaySection({ data, onSave, saving }: Props) {
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-1.5">
<Label>Owner IDs</Label>
<Input
value={(draft.owner_ids ?? []).join(", ")}
onChange={(e) => update({ owner_ids: e.target.value.split(",").map((s) => s.trim()).filter(Boolean) })}
placeholder="Comma-separated sender IDs"
<InfoLabel tip="Sender IDs with admin privileges. These users bypass rate limits and can manage configuration.">Owner IDs</InfoLabel>
<TagInput
value={draft.owner_ids ?? []}
onChange={(v) => update({ owner_ids: v })}
placeholder="Type ID and press Enter..."
/>
</div>
<div className="grid gap-1.5">
<Label>Allowed Origins</Label>
<Input
value={(draft.allowed_origins ?? []).join(", ")}
onChange={(e) => update({ allowed_origins: e.target.value.split(",").map((s) => s.trim()).filter(Boolean) })}
placeholder="Empty = allow all"
<InfoLabel tip="CORS allowed origins for the HTTP API. Empty means all origins are allowed.">Allowed Origins</InfoLabel>
<TagInput
value={draft.allowed_origins ?? []}
onChange={(v) => update({ allowed_origins: v })}
placeholder="Type origin and press Enter..."
/>
</div>
</div>
<div className="grid grid-cols-4 gap-4">
<div className="grid gap-1.5">
<Label>Max Message Chars</Label>
<InfoLabel tip="Maximum characters per inbound message. Messages exceeding this are rejected.">Max Message Chars</InfoLabel>
<Input
type="number"
value={draft.max_message_chars ?? ""}
@@ -125,7 +126,7 @@ export function GatewaySection({ data, onSave, saving }: Props) {
/>
</div>
<div className="grid gap-1.5">
<Label>Rate Limit (RPM)</Label>
<InfoLabel tip="Requests per minute per sender. 0 = no limit. Prevents abuse from individual users.">Rate Limit (RPM)</InfoLabel>
<Input
type="number"
value={draft.rate_limit_rpm ?? ""}
@@ -135,7 +136,7 @@ export function GatewaySection({ data, onSave, saving }: Props) {
/>
</div>
<div className="grid gap-1.5">
<Label>Inbound Debounce (ms)</Label>
<InfoLabel tip="Delay in ms before processing messages. Groups rapid messages into one request. -1 = disabled.">Inbound Debounce (ms)</InfoLabel>
<Input
type="number"
value={draft.inbound_debounce_ms ?? ""}
@@ -145,7 +146,7 @@ export function GatewaySection({ data, onSave, saving }: Props) {
/>
</div>
<div className="grid gap-1.5">
<Label>Injection Action</Label>
<InfoLabel tip="Action when prompt injection is detected. Off = disabled, Log = log only, Warn = add warning to prompt, Block = reject.">Injection Action</InfoLabel>
<Select value={draft.injection_action ?? "warn"} onValueChange={(v) => update({ injection_action: v })}>
<SelectTrigger>
<SelectValue />
@@ -2,9 +2,9 @@ import { useState, useEffect } from "react";
import { Save } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { InfoLabel } from "@/components/shared/info-label";
interface SessionsData {
storage?: string;
@@ -46,7 +46,7 @@ export function SessionsSection({ data, onSave, saving }: Props) {
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-1.5">
<Label>Storage Directory</Label>
<InfoLabel tip="Directory path where session data is persisted on disk (standalone mode).">Storage Directory</InfoLabel>
<Input
value={draft.storage ?? ""}
onChange={(e) => update({ storage: e.target.value })}
@@ -54,7 +54,7 @@ export function SessionsSection({ data, onSave, saving }: Props) {
/>
</div>
<div className="grid gap-1.5">
<Label>Main Key</Label>
<InfoLabel tip="Default session key name. Used as the main conversation thread identifier.">Main Key</InfoLabel>
<Input
value={draft.main_key ?? ""}
onChange={(e) => update({ main_key: e.target.value })}
@@ -65,7 +65,7 @@ export function SessionsSection({ data, onSave, saving }: Props) {
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-1.5">
<Label>Scope</Label>
<InfoLabel tip="Session isolation level. Per Sender = each user gets their own session. Global = all users share one session.">Scope</InfoLabel>
<Select value={draft.scope ?? "per-sender"} onValueChange={(v) => update({ scope: v })}>
<SelectTrigger>
<SelectValue />
@@ -77,7 +77,7 @@ export function SessionsSection({ data, onSave, saving }: Props) {
</Select>
</div>
<div className="grid gap-1.5">
<Label>DM Scope</Label>
<InfoLabel tip="How DM sessions are scoped. Controls session isolation for direct messages across different channels and accounts.">DM Scope</InfoLabel>
<Select value={draft.dm_scope ?? "per-channel-peer"} onValueChange={(v) => update({ dm_scope: v })}>
<SelectTrigger>
<SelectValue />
@@ -2,11 +2,11 @@ import { useState, useEffect } from "react";
import { Save } 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 { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { InfoLabel } from "@/components/shared/info-label";
import { KeyValueEditor } from "@/components/shared/key-value-editor";
interface TelemetryData {
enabled?: boolean;
@@ -27,12 +27,12 @@ interface Props {
export function TelemetrySection({ data, onSave, saving }: Props) {
const [draft, setDraft] = useState<TelemetryData>(data ?? DEFAULT);
const [headersText, setHeadersText] = useState("");
const [headers, setHeaders] = useState<Record<string, string>>({});
const [dirty, setDirty] = useState(false);
useEffect(() => {
setDraft(data ?? DEFAULT);
setHeadersText(data?.headers ? JSON.stringify(data.headers, null, 2) : "{}");
setHeaders(data?.headers ?? {});
setDirty(false);
}, [data]);
@@ -42,13 +42,7 @@ export function TelemetrySection({ data, onSave, saving }: Props) {
};
const handleSave = () => {
let headers: Record<string, string> | undefined;
try {
headers = JSON.parse(headersText);
} catch {
headers = draft.headers;
}
onSave({ ...draft, headers });
onSave({ ...draft, headers: Object.keys(headers).length > 0 ? headers : undefined });
};
if (!data) return null;
@@ -61,13 +55,13 @@ export function TelemetrySection({ data, onSave, saving }: Props) {
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<Label>Enabled</Label>
<InfoLabel tip="Enable OpenTelemetry export of LLM call traces and spans.">Enabled</InfoLabel>
<Switch checked={draft.enabled ?? false} onCheckedChange={(v) => update({ enabled: v })} />
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-1.5">
<Label>Endpoint</Label>
<InfoLabel tip="OTel collector endpoint address (host:port). E.g. localhost:4317 for gRPC or localhost:4318 for HTTP.">Endpoint</InfoLabel>
<Input
value={draft.endpoint ?? ""}
onChange={(e) => update({ endpoint: e.target.value })}
@@ -75,7 +69,7 @@ export function TelemetrySection({ data, onSave, saving }: Props) {
/>
</div>
<div className="grid gap-1.5">
<Label>Protocol</Label>
<InfoLabel tip="Transport protocol for exporting traces. gRPC is recommended for most setups.">Protocol</InfoLabel>
<Select value={draft.protocol ?? "grpc"} onValueChange={(v) => update({ protocol: v })}>
<SelectTrigger>
<SelectValue />
@@ -90,7 +84,7 @@ export function TelemetrySection({ data, onSave, saving }: Props) {
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-1.5">
<Label>Service Name</Label>
<InfoLabel tip="Service name reported in trace spans. Useful for distinguishing multiple gateway instances.">Service Name</InfoLabel>
<Input
value={draft.service_name ?? ""}
onChange={(e) => update({ service_name: e.target.value })}
@@ -98,21 +92,19 @@ export function TelemetrySection({ data, onSave, saving }: Props) {
/>
</div>
<div className="flex items-center justify-between">
<Label>Insecure (no TLS)</Label>
<InfoLabel tip="Disable TLS for the connection to the OTel collector. Use only for local/trusted networks.">Insecure (no TLS)</InfoLabel>
<Switch checked={draft.insecure ?? false} onCheckedChange={(v) => update({ insecure: v })} />
</div>
</div>
<div className="grid gap-1.5">
<Label>Headers (JSON)</Label>
<Textarea
value={headersText}
onChange={(e) => {
setHeadersText(e.target.value);
setDirty(true);
}}
className="min-h-[80px] font-mono text-xs"
placeholder='{"Authorization": "Bearer ..."}'
<InfoLabel tip="Additional HTTP headers sent with each export request. Useful for authentication tokens or routing metadata.">Headers</InfoLabel>
<KeyValueEditor
value={headers}
onChange={(v) => { setHeaders(v); setDirty(true); }}
keyPlaceholder="Header name"
valuePlaceholder="Header value"
addLabel="Add Header"
/>
</div>
@@ -8,6 +8,8 @@ import { Textarea } from "@/components/ui/textarea";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { InfoLabel } from "@/components/shared/info-label";
import { ToolNameSelect } from "@/components/shared/tool-name-select";
/* eslint-disable @typescript-eslint/no-explicit-any */
type ToolsData = Record<string, any>;
@@ -60,7 +62,7 @@ export function ToolsSection({ data, onSave, saving }: Props) {
{/* Profile & Lists */}
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-1.5">
<Label>Profile</Label>
<InfoLabel tip="Tool profile preset. Minimal = basic tools, Coding = filesystem + exec, Messaging = channels, Full = all tools enabled.">Profile</InfoLabel>
<Select value={draft.profile ?? ""} onValueChange={(v) => update({ profile: v })}>
<SelectTrigger>
<SelectValue placeholder="Select profile" />
@@ -74,7 +76,7 @@ export function ToolsSection({ data, onSave, saving }: Props) {
</Select>
</div>
<div className="grid gap-1.5">
<Label>Rate Limit (per hour)</Label>
<InfoLabel tip="Maximum tool executions per hour across all tools. 0 = no limit.">Rate Limit (per hour)</InfoLabel>
<Input
type="number"
value={draft.rate_limit_per_hour ?? ""}
@@ -85,37 +87,36 @@ export function ToolsSection({ data, onSave, saving }: Props) {
</div>
</div>
<div className="grid grid-cols-3 gap-4">
<div className="space-y-4">
<div className="grid gap-1.5">
<Label>Allow</Label>
<Input
value={(draft.allow ?? []).join(", ")}
onChange={(e) => update({ allow: e.target.value.split(",").map((s: string) => s.trim()).filter(Boolean) })}
placeholder="Comma-separated tool names"
<InfoLabel tip="Explicit whitelist of tool names. Only these tools will be available (overrides profile).">Allow</InfoLabel>
<ToolNameSelect
value={draft.allow ?? []}
onChange={(v) => { update({ allow: v }); }}
placeholder="Select tools to allow..."
/>
</div>
<div className="grid gap-1.5">
<Label>Deny</Label>
<Input
value={(draft.deny ?? []).join(", ")}
onChange={(e) => update({ deny: e.target.value.split(",").map((s: string) => s.trim()).filter(Boolean) })}
placeholder="Comma-separated"
<InfoLabel tip="Blacklist of tool names. These tools are disabled regardless of profile or allow list.">Deny</InfoLabel>
<ToolNameSelect
value={draft.deny ?? []}
onChange={(v) => { update({ deny: v }); }}
placeholder="Select tools to deny..."
/>
</div>
<div className="grid gap-1.5">
<Label>Also Allow</Label>
<Input
value={(draft.alsoAllow ?? []).join(", ")}
onChange={(e) => update({ alsoAllow: e.target.value.split(",").map((s: string) => s.trim()).filter(Boolean) })}
placeholder="Additive list"
<InfoLabel tip="Additional tools to enable on top of the profile. Additive to the profile's default set.">Also Allow</InfoLabel>
<ToolNameSelect
value={draft.alsoAllow ?? []}
onChange={(v) => { update({ alsoAllow: v }); }}
placeholder="Select additional tools..."
/>
</div>
</div>
<div className="flex items-center justify-between">
<div>
<Label>Scrub Credentials</Label>
<p className="text-xs text-muted-foreground">Auto-redact API keys in tool output</p>
<InfoLabel tip="Automatically redact API keys, tokens, and other credentials from tool output before sending to the LLM.">Scrub Credentials</InfoLabel>
</div>
<Switch
checked={draft.scrub_credentials !== false}
@@ -130,7 +131,7 @@ export function ToolsSection({ data, onSave, saving }: Props) {
<h4 className="mb-3 text-sm font-medium">Exec Approval</h4>
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-1.5">
<Label>Security</Label>
<InfoLabel tip="Security level for shell command execution. Deny = no exec, Allowlist = only matching patterns, Full = allow all commands.">Security</InfoLabel>
<Select value={exec.security ?? "full"} onValueChange={(v) => updateNested("execApproval", { security: v })}>
<SelectTrigger>
<SelectValue />
@@ -143,7 +144,7 @@ export function ToolsSection({ data, onSave, saving }: Props) {
</Select>
</div>
<div className="grid gap-1.5">
<Label>Ask Mode</Label>
<InfoLabel tip="When to ask the user for approval before executing a command. Off = never ask, On Miss = ask if not in allowlist, Always = always ask.">Ask Mode</InfoLabel>
<Select value={exec.ask ?? "off"} onValueChange={(v) => updateNested("execApproval", { ask: v })}>
<SelectTrigger>
<SelectValue />
+1 -1
View File
@@ -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-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/quality-gates-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/builtin-tools/builtin-tool-settings-dialog.tsx","./src/pages/builtin-tools/builtin-tools-page.tsx","./src/pages/builtin-tools/hooks/use-builtin-tools.ts","./src/pages/channLine truncated
{"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/info-label.tsx","./src/components/shared/key-value-editor.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/provider-model-select.tsx","./src/components/shared/require-auth.tsx","./src/components/shared/search-input.tsx","./src/components/shared/status-badge.tsx","./src/components/shared/tag-input.tsx","./src/components/shared/tool-name-select.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/quality-gates-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","./srLine truncated