refactor(ui): modularize web UI components — Phase 1 & 2

Split 19 large files (300-874 lines) into 37 focused modules.
All files target ≤200 lines for optimal maintainability.

Phase 1 (>400 lines): provider-overview, use-chat-messages,
system-settings-modal, trace-detail-dialog, media-provider-chain-form,
cli-credential-form-dialog, team-settings-tab, skills-page,
heartbeat-config-dialog, mcp-grants-dialog.

Phase 2 (200-400 lines): builtin-tools-page, quota-section,
file-tree, rich-content, session-detail-page, ai-defaults-section,
markdown-renderer, file-viewers, channel-managers-tab.

Also fixed DRY violations: exported shared types (VerifyResult,
ReasoningCapability, ManualEnvEntry), resolved circular import
in media-provider-chain files.

No behavior changes — pure refactoring. Build passes.
This commit is contained in:
viettranx committed 2026-04-03 22:12:10 +07:00
1 parent b2fda38e90
commit 1187146626
56 files changed
+4418 -4688

No files matched your search

@@ -0,0 +1,141 @@
/**
* Parser and deduplicator for chat message rich content blocks.
* Extracts special markup (media tags, file blocks, forwarded messages, etc.)
* into structured RichBlock objects for rendering.
*/
// <media:image>, <media:video>, <media:audio>, <media:voice>, <media:document>
const MEDIA_TAG_RE = /<media:(image|video|audio|voice|document|animation)>/g;
// <file name="..." mime="...">content</file>
const FILE_BLOCK_RE = /<file\s+name="([^"]+)"\s+mime="([^"]*)">([\s\S]*?)<\/file>/g;
// [Forwarded from Name at Date] at the start
const FORWARD_RE = /^\[Forwarded from (.+?) at (.+?)\]\n?/;
// [Replying to Sender]\nbody\n[/Replying]
const REPLY_RE = /\[Replying to (.+?)\]\n([\s\S]*?)\n\[\/Replying\]/;
// [Video received — ...]
const VIDEO_NOTICE_RE = /\[Video received[^\]]*\]/g;
// Location: Coordinates: lat, lng
const LOCATION_RE = /Coordinates:\s*([-\d.]+),\s*([-\d.]+)/;
export type RichBlock =
| { type: "markdown"; content: string }
| { type: "media"; mediaType: string }
| { type: "video-notice"; content: string }
| { type: "file"; name: string; mime: string; content: string }
| { type: "forward"; from: string; date: string }
| { type: "reply"; sender: string; body: string }
| { type: "location"; lat: string; lng: string };
/** Parse message content into rich blocks for rendering */
export function parseRichContent(content: string): RichBlock[] {
const blocks: RichBlock[] = [];
let text = content;
// Extract forward info (always at start)
const fwdMatch = text.match(FORWARD_RE);
if (fwdMatch) {
blocks.push({ type: "forward", from: fwdMatch[1]!, date: fwdMatch[2]! });
text = text.slice(fwdMatch[0].length);
}
// Extract reply block
const replyMatch = text.match(REPLY_RE);
let replyBlock: RichBlock | null = null;
if (replyMatch) {
replyBlock = { type: "reply", sender: replyMatch[1]!, body: replyMatch[2]! };
text = text.replace(REPLY_RE, "");
}
// Extract file blocks
const fileBlocks: RichBlock[] = [];
text = text.replace(FILE_BLOCK_RE, (_match, name: string, mime: string, body: string) => {
fileBlocks.push({ type: "file", name, mime, content: body });
return "";
});
// Extract media tags
const mediaBlocks: RichBlock[] = [];
text = text.replace(MEDIA_TAG_RE, (_match, mediaType: string) => {
mediaBlocks.push({ type: "media", mediaType });
return "";
});
// Extract video notices
text = text.replace(VIDEO_NOTICE_RE, (match) => {
mediaBlocks.push({ type: "video-notice", content: match });
return "";
});
// Extract location
const locMatch = text.match(LOCATION_RE);
let locationBlock: RichBlock | null = null;
if (locMatch) {
locationBlock = { type: "location", lat: locMatch[1]!, lng: locMatch[2]! };
text = text.replace(LOCATION_RE, "");
}
// Build final block list: forward → media → markdown → files → reply → location
if (mediaBlocks.length > 0) blocks.push(...mediaBlocks);
// Clean up leftover whitespace
const trimmed = text.replace(/\n{3,}/g, "\n\n").trim();
if (trimmed) blocks.push({ type: "markdown", content: trimmed });
if (fileBlocks.length > 0) blocks.push(...fileBlocks);
if (replyBlock) blocks.push(replyBlock);
if (locationBlock) blocks.push(locationBlock);
return blocks;
}
/** Extract basename from a markdown link/image URL, stripping query params. */
function linkBasename(line: string): string | null {
const m = line.match(/\]\(([^)]+)\)/);
if (!m?.[1]) return null;
const url = m[1].split("?")[0] ?? "";
const slash = url.lastIndexOf("/");
return slash >= 0 ? url.slice(slash + 1) : url;
}
/**
* Remove duplicate media links from content. When the same file (by basename)
* appears in both the agent's body text and an appended ContentSuffix block,
* keep the first occurrence and drop later duplicates.
*/
export function deduplicateMediaLinks(content: string): string {
const lines = content.split("\n");
// Pass 1: collect first occurrence index of each media basename (inline or standalone).
const firstSeen = new Map<string, number>();
for (let i = 0; i < lines.length; i++) {
const line = lines[i] ?? "";
if (!line.includes("](/")) continue;
const base = linkBasename(line);
if (base && !firstSeen.has(base)) firstSeen.set(base, i);
}
// Pass 2: drop STANDALONE link/image lines whose basename appeared in an earlier line.
const seen = new Set<string>();
const result: string[] = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i] ?? "";
const trimmed = line.trim();
const isStandaloneLink = (trimmed.startsWith("![") || trimmed.startsWith("[")) &&
trimmed.includes("](/") && trimmed.endsWith(")");
if (isStandaloneLink) {
const base = linkBasename(trimmed);
if (base) {
if (seen.has(base)) continue; // duplicate standalone — drop
seen.add(base);
// Also skip if this basename appeared inline in an earlier line
const first = firstSeen.get(base);
if (first !== undefined && first < i) continue;
}
}
result.push(line);
}
return result.join("\n");
}
+1 -152
View File
@@ -18,109 +18,7 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
// --- Patterns to detect special blocks ---
// <media:image>, <media:video>, <media:audio>, <media:voice>, <media:document>
const MEDIA_TAG_RE = /<media:(image|video|audio|voice|document|animation)>/g;
// <file name="..." mime="...">content</file>
const FILE_BLOCK_RE = /<file\s+name="([^"]+)"\s+mime="([^"]*)">([\s\S]*?)<\/file>/g;
// [Forwarded from Name at Date] at the start
const FORWARD_RE = /^\[Forwarded from (.+?) at (.+?)\]\n?/;
// [Replying to Sender]\nbody\n[/Replying]
const REPLY_RE = /\[Replying to (.+?)\]\n([\s\S]*?)\n\[\/Replying\]/;
// [Video received — ...]
const VIDEO_NOTICE_RE = /\[Video received[^\]]*\]/g;
// Location: Coordinates: lat, lng
const LOCATION_RE = /Coordinates:\s*([-\d.]+),\s*([-\d.]+)/;
type RichBlock =
| { type: "markdown"; content: string }
| { type: "media"; mediaType: string }
| { type: "video-notice"; content: string }
| { type: "file"; name: string; mime: string; content: string }
| { type: "forward"; from: string; date: string }
| { type: "reply"; sender: string; body: string }
| { type: "location"; lat: string; lng: string };
/** Parse message content into rich blocks for rendering */
function parseRichContent(content: string): RichBlock[] {
const blocks: RichBlock[] = [];
let text = content;
// Extract forward info (always at start)
const fwdMatch = text.match(FORWARD_RE);
if (fwdMatch) {
blocks.push({ type: "forward", from: fwdMatch[1]!, date: fwdMatch[2]! });
text = text.slice(fwdMatch[0].length);
}
// Extract reply block
const replyMatch = text.match(REPLY_RE);
let replyBlock: RichBlock | null = null;
if (replyMatch) {
replyBlock = { type: "reply", sender: replyMatch[1]!, body: replyMatch[2]! };
text = text.replace(REPLY_RE, "");
}
// Extract file blocks
const fileBlocks: RichBlock[] = [];
text = text.replace(FILE_BLOCK_RE, (_match, name: string, mime: string, body: string) => {
fileBlocks.push({ type: "file", name, mime, content: body });
return "";
});
// Extract media tags
const mediaBlocks: RichBlock[] = [];
text = text.replace(MEDIA_TAG_RE, (_match, mediaType: string) => {
mediaBlocks.push({ type: "media", mediaType });
return "";
});
// Extract video notices
text = text.replace(VIDEO_NOTICE_RE, (match) => {
mediaBlocks.push({ type: "video-notice", content: match });
return "";
});
// Extract location
const locMatch = text.match(LOCATION_RE);
let locationBlock: RichBlock | null = null;
if (locMatch) {
locationBlock = { type: "location", lat: locMatch[1]!, lng: locMatch[2]! };
text = text.replace(LOCATION_RE, "");
}
// Build final block list: forward → media → markdown → files → reply → location
if (mediaBlocks.length > 0) {
blocks.push(...mediaBlocks);
}
// Clean up leftover whitespace
const trimmed = text.replace(/\n{3,}/g, "\n\n").trim();
if (trimmed) {
blocks.push({ type: "markdown", content: trimmed });
}
if (fileBlocks.length > 0) {
blocks.push(...fileBlocks);
}
if (replyBlock) {
blocks.push(replyBlock);
}
if (locationBlock) {
blocks.push(locationBlock);
}
return blocks;
}
import { parseRichContent, deduplicateMediaLinks } from "./rich-content-parser";
// --- Renderers for each block type ---
@@ -253,55 +151,6 @@ function VideoNoticeBadge({ content }: { content: string }) {
);
}
// --- Media link dedup ---
/** Extract basename from a markdown link/image URL, stripping query params. */
function linkBasename(line: string): string | null {
const m = line.match(/\]\(([^)]+)\)/);
if (!m?.[1]) return null;
const url = m[1].split("?")[0] ?? "";
const slash = url.lastIndexOf("/");
return slash >= 0 ? url.slice(slash + 1) : url;
}
/**
* Remove duplicate media links from content. When the same file (by basename)
* appears in both the agent's body text and an appended ContentSuffix block,
* keep the first occurrence and drop later duplicates.
*/
function deduplicateMediaLinks(content: string): string {
const lines = content.split("\n");
// Pass 1: collect first occurrence index of each media basename (inline or standalone).
const firstSeen = new Map<string, number>();
for (let i = 0; i < lines.length; i++) {
const line = lines[i] ?? "";
if (!line.includes("](/")) continue;
const base = linkBasename(line);
if (base && !firstSeen.has(base)) firstSeen.set(base, i);
}
// Pass 2: drop STANDALONE link/image lines whose basename appeared in an earlier line.
const seen = new Set<string>();
const result: string[] = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i] ?? "";
const trimmed = line.trim();
const isStandaloneLink = (trimmed.startsWith("![") || trimmed.startsWith("[")) &&
trimmed.includes("](/") && trimmed.endsWith(")");
if (isStandaloneLink) {
const base = linkBasename(trimmed);
if (base) {
if (seen.has(base)) continue; // duplicate standalone — drop
seen.add(base);
// Also skip if this basename appeared inline in an earlier line
const first = firstSeen.get(base);
if (first !== undefined && first < i) continue;
}
}
result.push(line);
}
return result.join("\n");
}
// --- Main component ---
interface RichContentProps {
@@ -0,0 +1,88 @@
import { useTranslation } from "react-i18next";
import { Archive, Clock, Hash, Info } from "lucide-react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { ProviderModelSelect } from "@/components/shared/provider-model-select";
interface SystemSettingsCompactionCardProps {
compProvider: string;
setCompProvider: (v: string) => void;
compModel: string;
setCompModel: (v: string) => void;
compThreshold: string;
setCompThreshold: (v: string) => void;
compKeepRecent: string;
setCompKeepRecent: (v: string) => void;
compMaxTokens: string;
setCompMaxTokens: (v: string) => void;
}
export function SystemSettingsCompactionCard({
compProvider, setCompProvider,
compModel, setCompModel,
compThreshold, setCompThreshold,
compKeepRecent, setCompKeepRecent,
compMaxTokens, setCompMaxTokens,
}: SystemSettingsCompactionCardProps) {
const { t } = useTranslation("system-settings");
return (
<Card>
<CardHeader>
<CardTitle className="text-base">{t("compaction.title")}</CardTitle>
<CardDescription>{t("compaction.description")}</CardDescription>
</CardHeader>
<CardContent className="space-y-0 pt-0">
<div className="border-b pb-4">
<ProviderModelSelect
provider={compProvider}
onProviderChange={setCompProvider}
model={compModel}
onModelChange={setCompModel}
allowEmpty
providerPlaceholder={t("compaction.providerPlaceholder")}
/>
</div>
<div className="flex items-start justify-between gap-4 border-b py-4">
<div className="flex items-start gap-3">
<Archive className="mt-0.5 h-4 w-4 shrink-0 text-orange-500" />
<div className="space-y-0.5">
<Label className="text-sm font-medium">{t("compaction.threshold")}</Label>
<p className="text-xs text-muted-foreground">{t("compaction.thresholdHint")}</p>
</div>
</div>
<Input type="number" value={compThreshold} onChange={(e) => setCompThreshold(e.target.value)} placeholder="200" min={1} className="w-24 shrink-0 text-base md:text-sm" />
</div>
<div className="flex items-start justify-between gap-4 border-b py-4">
<div className="flex items-start gap-3">
<Clock className="mt-0.5 h-4 w-4 shrink-0 text-blue-500" />
<div className="space-y-0.5">
<Label className="text-sm font-medium">{t("compaction.keepRecent")}</Label>
<p className="text-xs text-muted-foreground">{t("compaction.keepRecentHint")}</p>
</div>
</div>
<Input type="number" value={compKeepRecent} onChange={(e) => setCompKeepRecent(e.target.value)} placeholder="40" min={1} className="w-24 shrink-0 text-base md:text-sm" />
</div>
<div className="flex items-start justify-between gap-4 border-b py-4">
<div className="flex items-start gap-3">
<Hash className="mt-0.5 h-4 w-4 shrink-0 text-orange-500" />
<div className="space-y-0.5">
<Label className="text-sm font-medium">{t("compaction.maxTokens")}</Label>
<p className="text-xs text-muted-foreground">{t("compaction.maxTokensHint")}</p>
</div>
</div>
<Input type="number" value={compMaxTokens} onChange={(e) => setCompMaxTokens(e.target.value)} placeholder="4096" min={256} className="w-24 shrink-0 text-base md:text-sm" />
</div>
<div className="flex items-start gap-2 rounded-md border border-orange-200 bg-orange-50 px-3 py-2 mt-4 text-xs text-orange-700 dark:border-orange-800 dark:bg-orange-950/30 dark:text-orange-300">
<Info className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<span>{t("compaction.info")}</span>
</div>
</CardContent>
</Card>
);
}
@@ -0,0 +1,59 @@
/** Curated 1536-dimension embedding models per provider type. */
export const EMBEDDING_MODELS: Record<string, { id: string; name: string }[]> = {
openai_compat: [
{ id: "text-embedding-3-small", name: "text-embedding-3-small (1536d)" },
{ id: "text-embedding-3-large", name: "text-embedding-3-large (3072d → 1536 via dimensions)" },
{ id: "text-embedding-ada-002", name: "text-embedding-ada-002 (1536d)" },
],
openrouter: [
{ id: "openai/text-embedding-3-small", name: "openai/text-embedding-3-small (1536d)" },
{ id: "openai/text-embedding-3-large", name: "openai/text-embedding-3-large (3072d → 1536)" },
{ id: "openai/text-embedding-ada-002", name: "openai/text-embedding-ada-002 (1536d)" },
],
gemini_native: [
{ id: "gemini-embedding-001", name: "gemini-embedding-001 (3072d → 1536 via dimensions)" },
],
mistral: [
{ id: "codestral-embed", name: "codestral-embed (1536d default)" },
],
dashscope: [
{ id: "text-embedding-v3", name: "text-embedding-v3 (1536 via dimensions)" },
],
cohere: [
{ id: "embed-v4", name: "embed-v4 (1536d native)" },
],
};
export const DEFAULT_EMBEDDING_MODELS: { id: string; name: string }[] = [];
export interface InitState {
embProvider: string;
embModel: string;
embMaxChunkLen: string;
embChunkOverlap: string;
toolStatus: boolean;
blockReply: boolean;
intentClassify: boolean;
compProvider: string;
compModel: string;
compThreshold: string;
compKeepRecent: string;
compMaxTokens: string;
kgProvider: string;
kgModel: string;
kgMinConfidence: string;
}
export const DEFAULTS: InitState = {
embProvider: "", embModel: "",
embMaxChunkLen: "", embChunkOverlap: "",
toolStatus: true, blockReply: false, intentClassify: true,
compProvider: "", compModel: "",
compThreshold: "", compKeepRecent: "", compMaxTokens: "",
kgProvider: "", kgModel: "", kgMinConfidence: "0.75",
};
export function parseBool(v: string | undefined, fallback: boolean): boolean {
if (v === undefined) return fallback;
return v !== "false" && v !== "0";
}
@@ -0,0 +1,116 @@
import { useTranslation } from "react-i18next";
import { Brain, Info, Loader2, AlertTriangle, CheckCircle2, XCircle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { ProviderModelSelect } from "@/components/shared/provider-model-select";
interface EmbVerifyResult {
valid: boolean;
dimensions?: number;
dimension_mismatch?: boolean;
error?: string;
}
interface SystemSettingsEmbeddingCardProps {
embProvider: string;
setEmbProvider: (v: string) => void;
embModel: string;
setEmbModel: (v: string) => void;
embMaxChunkLen: string;
setEmbMaxChunkLen: (v: string) => void;
embChunkOverlap: string;
setEmbChunkOverlap: (v: string) => void;
extraModels: { id: string; name: string }[];
onVerify: () => void;
verifying: boolean;
verifyResult: EmbVerifyResult | null;
canVerify: boolean;
}
export function SystemSettingsEmbeddingCard({
embProvider, setEmbProvider,
embModel, setEmbModel,
embMaxChunkLen, setEmbMaxChunkLen,
embChunkOverlap, setEmbChunkOverlap,
extraModels, onVerify, verifying, verifyResult, canVerify,
}: SystemSettingsEmbeddingCardProps) {
const { t } = useTranslation("system-settings");
return (
<Card className="border-blue-200 dark:border-blue-800">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Brain className="h-4 w-4 text-blue-500" />
{t("embedding.title")}
</CardTitle>
<CardDescription>{t("embedding.description")}</CardDescription>
</CardHeader>
<CardContent className="space-y-4 pt-0">
<div className="flex items-start gap-2 rounded-md border border-blue-200 bg-blue-50 px-3 py-2 text-xs text-blue-700 dark:border-blue-800 dark:bg-blue-950/30 dark:text-blue-300">
<Info className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<div className="space-y-1">
<p>{t("embedding.importance")}</p>
<p className="opacity-75">{t("embedding.supportedProviders")}</p>
</div>
</div>
<ProviderModelSelect
provider={embProvider}
onProviderChange={(v) => { setEmbProvider(v); setEmbModel(""); }}
model={embModel}
onModelChange={setEmbModel}
allowEmpty
showVerify={false}
extraModels={extraModels}
modelFilter="embed"
providerLabel={t("embedding.provider")}
modelLabel={t("embedding.model")}
providerTip={t("embedding.providerTip")}
modelTip={t("embedding.modelTip")}
providerPlaceholder={t("embedding.providerPlaceholder")}
modelPlaceholder={t("embedding.modelPlaceholder")}
/>
<div className="flex items-center gap-3">
<Button type="button" variant="outline" size="sm" disabled={!canVerify || verifying} onClick={onVerify}>
{verifying ? <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" /> : null}
{t("embedding.verify")}
</Button>
{verifyResult && (
<span className={`flex items-center gap-1 text-xs ${
verifyResult.valid
? verifyResult.dimension_mismatch ? "text-amber-600 dark:text-amber-400" : "text-emerald-600 dark:text-emerald-400"
: "text-destructive"
}`}>
{verifyResult.valid ? (
<>
{verifyResult.dimension_mismatch ? <AlertTriangle className="h-3.5 w-3.5" /> : <CheckCircle2 className="h-3.5 w-3.5" />}
{verifyResult.dimension_mismatch
? t("embedding.dimensionsMismatch", { count: verifyResult.dimensions })
: t("embedding.dimensions", { count: verifyResult.dimensions })}
</>
) : (
<><XCircle className="h-3.5 w-3.5" />{verifyResult.error || t("embedding.verifyFailed")}</>
)}
</span>
)}
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="embMaxChunkLen" className="text-xs">{t("embedding.maxChunkLen")}</Label>
<Input id="embMaxChunkLen" type="number" placeholder="1000" value={embMaxChunkLen} onChange={(e) => setEmbMaxChunkLen(e.target.value)} className="text-base md:text-sm" />
<p className="text-xs text-muted-foreground">{t("embedding.maxChunkLenHint")}</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="embChunkOverlap" className="text-xs">{t("embedding.chunkOverlap")}</Label>
<Input id="embChunkOverlap" type="number" placeholder="200" value={embChunkOverlap} onChange={(e) => setEmbChunkOverlap(e.target.value)} className="text-base md:text-sm" />
<p className="text-xs text-muted-foreground">{t("embedding.chunkOverlapHint")}</p>
</div>
</div>
</CardContent>
</Card>
);
}
@@ -1,13 +1,8 @@
import { useState, useEffect, useCallback } from "react";
import { useTranslation } from "react-i18next";
import {
Settings2, Loader2, Save, AlertTriangle, Info, ExternalLink,
Brain, Eye, MessageSquareText, Archive, Clock, Hash, CheckCircle2, XCircle, Network,
} from "lucide-react";
import { Settings2, Loader2, Save, AlertTriangle, Info, ExternalLink, Network } from "lucide-react";
import { Link } from "react-router";
import {
Dialog, DialogContent, DialogHeader, DialogTitle,
} from "@/components/ui/dialog";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
@@ -19,80 +14,16 @@ import { useProviderVerify } from "@/pages/providers/hooks/use-provider-verify";
import { useProviders } from "@/pages/providers/hooks/use-providers";
import { useHttp } from "@/hooks/use-ws";
import { toast } from "@/stores/use-toast-store";
// Curated 1536-dimension embedding models per provider type.
// Verified 1536d embedding models per provider type.
// Only providers with confirmed 1536-dimension output are listed.
const EMBEDDING_MODELS: Record<string, { id: string; name: string }[]> = {
// OpenAI — native 1536d
openai_compat: [
{ id: "text-embedding-3-small", name: "text-embedding-3-small (1536d)" },
{ id: "text-embedding-3-large", name: "text-embedding-3-large (3072d → 1536 via dimensions)" },
{ id: "text-embedding-ada-002", name: "text-embedding-ada-002 (1536d)" },
],
// OpenRouter — proxied OpenAI models
openrouter: [
{ id: "openai/text-embedding-3-small", name: "openai/text-embedding-3-small (1536d)" },
{ id: "openai/text-embedding-3-large", name: "openai/text-embedding-3-large (3072d → 1536)" },
{ id: "openai/text-embedding-ada-002", name: "openai/text-embedding-ada-002 (1536d)" },
],
// Gemini — gemini-embedding-001 (3072d native, truncate to 1536 via dimensions param)
gemini_native: [
{ id: "gemini-embedding-001", name: "gemini-embedding-001 (3072d → 1536 via dimensions)" },
],
// Mistral — codestral-embed defaults to 1536d (MRL)
mistral: [
{ id: "codestral-embed", name: "codestral-embed (1536d default)" },
],
// DashScope/Qwen — text-embedding-v3 (custom dimensions support)
dashscope: [
{ id: "text-embedding-v3", name: "text-embedding-v3 (1536 via dimensions)" },
],
// Cohere — embed-v4 native 1536d
cohere: [
{ id: "embed-v4", name: "embed-v4 (1536d native)" },
],
};
// Fallback for unlisted provider types — no curated models
const DEFAULT_EMBEDDING_MODELS: { id: string; name: string }[] = [];
import { EMBEDDING_MODELS, DEFAULT_EMBEDDING_MODELS, DEFAULTS, parseBool, type InitState } from "./system-settings-constants";
import { SystemSettingsEmbeddingCard } from "./system-settings-embedding-card";
import { SystemSettingsCompactionCard } from "./system-settings-compaction-card";
import { Eye, MessageSquareText, Brain } from "lucide-react";
interface SystemSettingsModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
interface InitState {
embProvider: string;
embModel: string;
embMaxChunkLen: string;
embChunkOverlap: string;
toolStatus: boolean;
blockReply: boolean;
intentClassify: boolean;
compProvider: string;
compModel: string;
compThreshold: string;
compKeepRecent: string;
compMaxTokens: string;
kgProvider: string;
kgModel: string;
kgMinConfidence: string;
}
const DEFAULTS: InitState = {
embProvider: "", embModel: "",
embMaxChunkLen: "", embChunkOverlap: "",
toolStatus: true, blockReply: false, intentClassify: true,
compProvider: "", compModel: "",
compThreshold: "", compKeepRecent: "", compMaxTokens: "",
kgProvider: "", kgModel: "", kgMinConfidence: "0.75",
};
function parseBool(v: string | undefined, fallback: boolean): boolean {
if (v === undefined) return fallback;
return v !== "false" && v !== "0";
}
export function SystemSettingsModal({ open, onOpenChange }: SystemSettingsModalProps) {
const { t } = useTranslation("system-settings");
const http = useHttp();
@@ -114,7 +45,7 @@ export function SystemSettingsModal({ open, onOpenChange }: SystemSettingsModalP
const [blockReply, setBlockReply] = useState(false);
const [intentClassify, setIntentClassify] = useState(true);
// Pending Compaction
// Compaction
const [compProvider, setCompProvider] = useState("");
const [compModel, setCompModel] = useState("");
const [compThreshold, setCompThreshold] = useState("");
@@ -131,38 +62,21 @@ export function SystemSettingsModal({ open, onOpenChange }: SystemSettingsModalP
kgSettings?: { extraction_provider?: string; extraction_model?: string; min_confidence?: number },
) => {
const s: InitState = {
embProvider: configs["embedding.provider"] ?? "",
embModel: configs["embedding.model"] ?? "",
embMaxChunkLen: configs["embedding.max_chunk_len"] ?? "",
embChunkOverlap: configs["embedding.chunk_overlap"] ?? "",
toolStatus: parseBool(configs["gateway.tool_status"], true),
blockReply: parseBool(configs["gateway.block_reply"], false),
embProvider: configs["embedding.provider"] ?? "", embModel: configs["embedding.model"] ?? "",
embMaxChunkLen: configs["embedding.max_chunk_len"] ?? "", embChunkOverlap: configs["embedding.chunk_overlap"] ?? "",
toolStatus: parseBool(configs["gateway.tool_status"], true), blockReply: parseBool(configs["gateway.block_reply"], false),
intentClassify: parseBool(configs["gateway.intent_classify"], true),
compProvider: configs["compaction.provider"] ?? "",
compModel: configs["compaction.model"] ?? "",
compThreshold: configs["compaction.threshold"] ?? "",
compKeepRecent: configs["compaction.keep_recent"] ?? "",
compProvider: configs["compaction.provider"] ?? "", compModel: configs["compaction.model"] ?? "",
compThreshold: configs["compaction.threshold"] ?? "", compKeepRecent: configs["compaction.keep_recent"] ?? "",
compMaxTokens: configs["compaction.max_tokens"] ?? "",
kgProvider: kgSettings?.extraction_provider ?? "",
kgModel: kgSettings?.extraction_model ?? "",
kgProvider: kgSettings?.extraction_provider ?? "", kgModel: kgSettings?.extraction_model ?? "",
kgMinConfidence: String(kgSettings?.min_confidence ?? 0.75),
};
setInit(s);
setEmbProvider(s.embProvider);
setEmbModel(s.embModel);
setEmbMaxChunkLen(s.embMaxChunkLen);
setEmbChunkOverlap(s.embChunkOverlap);
setToolStatus(s.toolStatus);
setBlockReply(s.blockReply);
setIntentClassify(s.intentClassify);
setCompProvider(s.compProvider);
setCompModel(s.compModel);
setCompThreshold(s.compThreshold);
setCompKeepRecent(s.compKeepRecent);
setCompMaxTokens(s.compMaxTokens);
setKgProvider(s.kgProvider);
setKgModel(s.kgModel);
setKgMinConfidence(s.kgMinConfidence);
setEmbProvider(s.embProvider); setEmbModel(s.embModel); setEmbMaxChunkLen(s.embMaxChunkLen); setEmbChunkOverlap(s.embChunkOverlap);
setToolStatus(s.toolStatus); setBlockReply(s.blockReply); setIntentClassify(s.intentClassify);
setCompProvider(s.compProvider); setCompModel(s.compModel); setCompThreshold(s.compThreshold); setCompKeepRecent(s.compKeepRecent); setCompMaxTokens(s.compMaxTokens);
setKgProvider(s.kgProvider); setKgModel(s.kgModel); setKgMinConfidence(s.kgMinConfidence);
resetEmb();
}, [resetEmb]);
@@ -185,17 +99,8 @@ export function SystemSettingsModal({ open, onOpenChange }: SystemSettingsModalP
const embChanged = embProvider !== init.embProvider || embModel !== init.embModel;
const embVerified = embResult?.valid === true;
const saveDisabled = saving || (embChanged && !embVerified);
const selectedEmbProviderData = providers.find((p) => p.name === embProvider);
const embExtraModels = selectedEmbProviderData
? (EMBEDDING_MODELS[selectedEmbProviderData.provider_type] ?? DEFAULT_EMBEDDING_MODELS)
: DEFAULT_EMBEDDING_MODELS;
const handleVerifyEmb = () => {
if (!selectedEmbProviderData) return;
// Always request 1536 dims — pgvector schema requires vector(1536).
verifyEmbedding(selectedEmbProviderData.id, embModel.trim() || undefined, 1536);
};
const embExtraModels = selectedEmbProviderData ? (EMBEDDING_MODELS[selectedEmbProviderData.provider_type] ?? DEFAULT_EMBEDDING_MODELS) : DEFAULT_EMBEDDING_MODELS;
const handleSave = async () => {
setSaving(true);
@@ -213,64 +118,22 @@ export function SystemSettingsModal({ open, onOpenChange }: SystemSettingsModalP
if (compThreshold !== init.compThreshold) updates["compaction.threshold"] = compThreshold;
if (compKeepRecent !== init.compKeepRecent) updates["compaction.keep_recent"] = compKeepRecent;
if (compMaxTokens !== init.compMaxTokens) updates["compaction.max_tokens"] = compMaxTokens;
for (const [key, value] of Object.entries(updates)) {
await http.put(`/v1/system-configs/${key}`, { value });
}
// KG extraction — save via builtin tools API
for (const [key, value] of Object.entries(updates)) await http.put(`/v1/system-configs/${key}`, { value });
const kgChanged = kgProvider !== init.kgProvider || kgModel !== init.kgModel || kgMinConfidence !== init.kgMinConfidence;
if (kgChanged) {
await http.put("/v1/tools/builtin/knowledge_graph_search", {
settings: {
extraction_provider: kgProvider,
extraction_model: kgModel,
min_confidence: Number(kgMinConfidence) || 0.75,
extract_on_memory_write: !!(kgProvider && kgModel),
},
settings: { extraction_provider: kgProvider, extraction_model: kgModel, min_confidence: Number(kgMinConfidence) || 0.75, extract_on_memory_write: !!(kgProvider && kgModel) },
});
}
toast.success(t("saved"));
onOpenChange(false);
} catch (err) {
toast.error(err instanceof Error ? err.message : t("saveFailed"));
} finally {
setSaving(false);
}
toast.success(t("saved")); onOpenChange(false);
} catch (err) { toast.error(err instanceof Error ? err.message : t("saveFailed")); }
finally { setSaving(false); }
};
// UX Behavior switch items — reuses FeatureSwitchGroup pattern from config page
const uxItems: FeatureSwitchItem[] = [
{
icon: Eye,
iconClass: "text-blue-500",
label: t("ux.toolStatus"),
hint: t("ux.toolStatusHint"),
checked: toolStatus,
onCheckedChange: setToolStatus,
infoWhenOn: t("ux.toolStatusInfo"),
infoClass: "border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-800 dark:bg-blue-950/30 dark:text-blue-300",
},
{
icon: MessageSquareText,
iconClass: "text-emerald-500",
label: t("ux.blockReply"),
hint: t("ux.blockReplyHint"),
checked: blockReply,
onCheckedChange: setBlockReply,
infoWhenOn: t("ux.blockReplyInfo"),
infoClass: "border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-800 dark:bg-emerald-950/30 dark:text-emerald-300",
},
{
icon: Brain,
iconClass: "text-orange-500",
label: t("ux.intentClassify"),
hint: t("ux.intentClassifyHint"),
checked: intentClassify,
onCheckedChange: setIntentClassify,
infoWhenOn: t("ux.intentClassifyInfo"),
infoClass: "border-orange-200 bg-orange-50 text-orange-700 dark:border-orange-800 dark:bg-orange-950/30 dark:text-orange-300",
},
{ icon: Eye, iconClass: "text-blue-500", label: t("ux.toolStatus"), hint: t("ux.toolStatusHint"), checked: toolStatus, onCheckedChange: setToolStatus, infoWhenOn: t("ux.toolStatusInfo"), infoClass: "border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-800 dark:bg-blue-950/30 dark:text-blue-300" },
{ icon: MessageSquareText, iconClass: "text-emerald-500", label: t("ux.blockReply"), hint: t("ux.blockReplyHint"), checked: blockReply, onCheckedChange: setBlockReply, infoWhenOn: t("ux.blockReplyInfo"), infoClass: "border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-800 dark:bg-emerald-950/30 dark:text-emerald-300" },
{ icon: Brain, iconClass: "text-orange-500", label: t("ux.intentClassify"), hint: t("ux.intentClassifyHint"), checked: intentClassify, onCheckedChange: setIntentClassify, infoWhenOn: t("ux.intentClassifyInfo"), infoClass: "border-orange-200 bg-orange-50 text-orange-700 dark:border-orange-800 dark:bg-orange-950/30 dark:text-orange-300" },
];
return (
@@ -278,231 +141,53 @@ export function SystemSettingsModal({ open, onOpenChange }: SystemSettingsModalP
<DialogContent className="flex max-h-[90vh] w-[95vw] flex-col sm:max-w-3xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Settings2 className="h-5 w-5" />
{t("title")}
<Settings2 className="h-5 w-5" />{t("title")}
</DialogTitle>
</DialogHeader>
{loading ? (
<div className="flex flex-1 items-center justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
<div className="flex flex-1 items-center justify-center py-12"><Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /></div>
) : (
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto -mx-4 px-4 sm:-mx-6 sm:px-6">
{/* ── Embedding ── */}
<Card className="border-blue-200 dark:border-blue-800">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Brain className="h-4 w-4 text-blue-500" />
{t("embedding.title")}
</CardTitle>
<CardDescription>{t("embedding.description")}</CardDescription>
</CardHeader>
<CardContent className="space-y-4 pt-0">
<div className="flex items-start gap-2 rounded-md border border-blue-200 bg-blue-50 px-3 py-2 text-xs text-blue-700 dark:border-blue-800 dark:bg-blue-950/30 dark:text-blue-300">
<Info className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<div className="space-y-1">
<p>{t("embedding.importance")}</p>
<p className="opacity-75">{t("embedding.supportedProviders")}</p>
</div>
</div>
<SystemSettingsEmbeddingCard
embProvider={embProvider} setEmbProvider={setEmbProvider}
embModel={embModel} setEmbModel={setEmbModel}
embMaxChunkLen={embMaxChunkLen} setEmbMaxChunkLen={setEmbMaxChunkLen}
embChunkOverlap={embChunkOverlap} setEmbChunkOverlap={setEmbChunkOverlap}
extraModels={embExtraModels}
onVerify={() => { if (selectedEmbProviderData) verifyEmbedding(selectedEmbProviderData.id, embModel.trim() || undefined, 1536); }}
verifying={embVerifying} verifyResult={embResult}
canVerify={!!selectedEmbProviderData && !!embModel.trim()}
/>
<ProviderModelSelect
provider={embProvider}
onProviderChange={(v) => { setEmbProvider(v); setEmbModel(""); }}
model={embModel}
onModelChange={setEmbModel}
allowEmpty
showVerify={false}
extraModels={embExtraModels}
modelFilter="embed"
providerLabel={t("embedding.provider")}
modelLabel={t("embedding.model")}
providerTip={t("embedding.providerTip")}
modelTip={t("embedding.modelTip")}
providerPlaceholder={t("embedding.providerPlaceholder")}
modelPlaceholder={t("embedding.modelPlaceholder")}
/>
<div className="flex items-center gap-3">
<Button
type="button"
variant="outline"
size="sm"
disabled={!selectedEmbProviderData || !embModel.trim() || embVerifying}
onClick={handleVerifyEmb}
>
{embVerifying ? <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" /> : null}
{t("embedding.verify")}
</Button>
{embResult && (
<span className={`flex items-center gap-1 text-xs ${
embResult.valid
? embResult.dimension_mismatch
? "text-amber-600 dark:text-amber-400"
: "text-emerald-600 dark:text-emerald-400"
: "text-destructive"
}`}>
{embResult.valid ? (
<>
{embResult.dimension_mismatch ? (
<AlertTriangle className="h-3.5 w-3.5" />
) : (
<CheckCircle2 className="h-3.5 w-3.5" />
)}
{embResult.dimension_mismatch
? t("embedding.dimensionsMismatch", { count: embResult.dimensions })
: t("embedding.dimensions", { count: embResult.dimensions })}
</>
) : (
<>
<XCircle className="h-3.5 w-3.5" />
{embResult.error || t("embedding.verifyFailed")}
</>
)}
</span>
)}
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="embMaxChunkLen" className="text-xs">{t("embedding.maxChunkLen")}</Label>
<Input id="embMaxChunkLen" type="number" placeholder="1000" value={embMaxChunkLen} onChange={(e) => setEmbMaxChunkLen(e.target.value)} className="text-base md:text-sm" />
<p className="text-xs text-muted-foreground">{t("embedding.maxChunkLenHint")}</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="embChunkOverlap" className="text-xs">{t("embedding.chunkOverlap")}</Label>
<Input id="embChunkOverlap" type="number" placeholder="200" value={embChunkOverlap} onChange={(e) => setEmbChunkOverlap(e.target.value)} className="text-base md:text-sm" />
<p className="text-xs text-muted-foreground">{t("embedding.chunkOverlapHint")}</p>
</div>
</div>
</CardContent>
</Card>
{/* ── Knowledge Graph Extraction ── */}
{/* Knowledge Graph */}
<Card className="border-violet-200 dark:border-violet-800">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Network className="h-4 w-4 text-violet-500" />
{t("kg.title")}
</CardTitle>
<CardTitle className="flex items-center gap-2 text-base"><Network className="h-4 w-4 text-violet-500" />{t("kg.title")}</CardTitle>
<CardDescription>{t("kg.description")}</CardDescription>
</CardHeader>
<CardContent className="space-y-4 pt-0">
<ProviderModelSelect
provider={kgProvider}
onProviderChange={(v) => { setKgProvider(v); setKgModel(""); }}
model={kgModel}
onModelChange={setKgModel}
allowEmpty
providerLabel={t("kg.provider")}
modelLabel={t("kg.model")}
providerTip={t("kg.providerTip")}
modelTip={t("kg.modelTip")}
providerPlaceholder={t("kg.providerPlaceholder")}
modelPlaceholder={t("kg.modelPlaceholder")}
/>
<ProviderModelSelect provider={kgProvider} onProviderChange={(v) => { setKgProvider(v); setKgModel(""); }} model={kgModel} onModelChange={setKgModel} allowEmpty providerLabel={t("kg.provider")} modelLabel={t("kg.model")} providerTip={t("kg.providerTip")} modelTip={t("kg.modelTip")} providerPlaceholder={t("kg.providerPlaceholder")} modelPlaceholder={t("kg.modelPlaceholder")} />
<div className="space-y-1.5">
<Label htmlFor="kgMinConf" className="text-xs">{t("kg.minConfidence")}</Label>
<Input
id="kgMinConf"
type="number"
min={0} max={1} step={0.05}
placeholder="0.75"
value={kgMinConfidence}
onChange={(e) => setKgMinConfidence(e.target.value)}
className="max-w-[120px] text-base md:text-sm"
/>
<Input id="kgMinConf" type="number" min={0} max={1} step={0.05} placeholder="0.75" value={kgMinConfidence} onChange={(e) => setKgMinConfidence(e.target.value)} className="max-w-[120px] text-base md:text-sm" />
<p className="text-xs text-muted-foreground">{t("kg.minConfidenceHint")}</p>
</div>
<div className="flex items-start gap-2 rounded-md border border-violet-200 bg-violet-50 px-3 py-2 text-xs text-violet-700 dark:border-violet-800 dark:bg-violet-950/30 dark:text-violet-300">
<Info className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<span>{t("kg.info")}</span>
<Info className="mt-0.5 h-3.5 w-3.5 shrink-0" /><span>{t("kg.info")}</span>
</div>
</CardContent>
</Card>
{/* ── UX Behavior — uses FeatureSwitchGroup (same as config page) ── */}
<FeatureSwitchGroup
title={t("ux.title")}
description={t("ux.description")}
items={uxItems}
<FeatureSwitchGroup title={t("ux.title")} description={t("ux.description")} items={uxItems} />
<SystemSettingsCompactionCard
compProvider={compProvider} setCompProvider={setCompProvider}
compModel={compModel} setCompModel={setCompModel}
compThreshold={compThreshold} setCompThreshold={setCompThreshold}
compKeepRecent={compKeepRecent} setCompKeepRecent={setCompKeepRecent}
compMaxTokens={compMaxTokens} setCompMaxTokens={setCompMaxTokens}
/>
{/* ── Pending Compaction ── */}
<Card>
<CardHeader>
<CardTitle className="text-base">{t("compaction.title")}</CardTitle>
<CardDescription>{t("compaction.description")}</CardDescription>
</CardHeader>
<CardContent className="space-y-0 pt-0">
<div className="border-b pb-4">
<ProviderModelSelect
provider={compProvider}
onProviderChange={setCompProvider}
model={compModel}
onModelChange={setCompModel}
allowEmpty
providerPlaceholder={t("compaction.providerPlaceholder")}
/>
</div>
<div className="flex items-start justify-between gap-4 border-b py-4">
<div className="flex items-start gap-3">
<Archive className="mt-0.5 h-4 w-4 shrink-0 text-orange-500" />
<div className="space-y-0.5">
<Label className="text-sm font-medium">{t("compaction.threshold")}</Label>
<p className="text-xs text-muted-foreground">{t("compaction.thresholdHint")}</p>
</div>
</div>
<Input
type="number" value={compThreshold}
onChange={(e) => setCompThreshold(e.target.value)}
placeholder="200" min={1}
className="w-24 shrink-0 text-base md:text-sm"
/>
</div>
<div className="flex items-start justify-between gap-4 border-b py-4">
<div className="flex items-start gap-3">
<Clock className="mt-0.5 h-4 w-4 shrink-0 text-blue-500" />
<div className="space-y-0.5">
<Label className="text-sm font-medium">{t("compaction.keepRecent")}</Label>
<p className="text-xs text-muted-foreground">{t("compaction.keepRecentHint")}</p>
</div>
</div>
<Input
type="number" value={compKeepRecent}
onChange={(e) => setCompKeepRecent(e.target.value)}
placeholder="40" min={1}
className="w-24 shrink-0 text-base md:text-sm"
/>
</div>
<div className="flex items-start justify-between gap-4 border-b py-4">
<div className="flex items-start gap-3">
<Hash className="mt-0.5 h-4 w-4 shrink-0 text-orange-500" />
<div className="space-y-0.5">
<Label className="text-sm font-medium">{t("compaction.maxTokens")}</Label>
<p className="text-xs text-muted-foreground">{t("compaction.maxTokensHint")}</p>
</div>
</div>
<Input
type="number" value={compMaxTokens}
onChange={(e) => setCompMaxTokens(e.target.value)}
placeholder="4096" min={256}
className="w-24 shrink-0 text-base md:text-sm"
/>
</div>
<div className="flex items-start gap-2 rounded-md border border-orange-200 bg-orange-50 px-3 py-2 mt-4 text-xs text-orange-700 dark:border-orange-800 dark:bg-orange-950/30 dark:text-orange-300">
<Info className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<span>{t("compaction.info")}</span>
</div>
</CardContent>
</Card>
</div>
)}
@@ -510,23 +195,15 @@ export function SystemSettingsModal({ open, onOpenChange }: SystemSettingsModalP
<div className="flex flex-col gap-3 border-t pt-4 shrink-0">
{embChanged && !embVerified && (
<p className="flex items-center gap-1.5 text-xs text-amber-600 dark:text-amber-400">
<AlertTriangle className="h-3.5 w-3.5 shrink-0" />
{t("embedding.verifyRequired")}
<AlertTriangle className="h-3.5 w-3.5 shrink-0" />{t("embedding.verifyRequired")}
</p>
)}
<div className="flex items-center justify-between gap-2">
<Link
to="/config"
onClick={() => onOpenChange(false)}
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<ExternalLink className="h-3 w-3" />
{t("moreConfig")}
<Link to="/config" onClick={() => onOpenChange(false)} className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors">
<ExternalLink className="h-3 w-3" />{t("moreConfig")}
</Link>
<div className="flex gap-2">
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={saving}>
{t("cancel")}
</Button>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={saving}>{t("cancel")}</Button>
<Button onClick={handleSave} disabled={saveDisabled}>
{saving ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Save className="mr-2 h-4 w-4" />}
{saving ? t("saving") : t("save")}
@@ -0,0 +1,64 @@
/**
* DnD wrapper components for the file tree: draggable items and droppable folders.
* Uses @dnd-kit/core primitives.
*/
import { useDraggable, useDroppable } from "@dnd-kit/core";
/** Draggable wrapper for tree items (files and folders). */
export function DraggableItem({
id,
enabled,
children,
}: {
id: string;
enabled: boolean;
children: React.ReactNode;
}) {
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
id,
disabled: !enabled,
});
return (
<div
ref={setNodeRef}
{...(enabled ? { ...listeners, ...attributes } : {})}
className={isDragging ? "opacity-40" : ""}
>
{children}
</div>
);
}
/** Droppable wrapper for folder items. */
export function DroppableFolder({
id,
enabled,
children,
}: {
id: string;
enabled: boolean;
children: (props: { isDropTarget: boolean }) => React.ReactNode;
}) {
const { setNodeRef, isOver } = useDroppable({
id,
disabled: !enabled,
});
return (
<div ref={setNodeRef}>
{children({ isDropTarget: isOver })}
</div>
);
}
/** Root-level droppable zone — dropping here moves to root (""). */
export function RootDropZone({ children }: { children: React.ReactNode }) {
const { setNodeRef, isOver } = useDroppable({ id: "__root__" });
return (
<div ref={setNodeRef} className={`min-h-full ${isOver ? "bg-primary/5" : ""}`}>
{children}
</div>
);
}
@@ -0,0 +1,38 @@
/**
* File icon component for the file tree — maps file extensions to Lucide icons.
*/
import {
FileText,
FileCode2,
File,
FileImage,
FileJson2,
FileSpreadsheet,
FileTerminal,
FileArchive,
FileVideo,
FileAudio,
FileCog,
FileType,
FileLock,
} from "lucide-react";
import { extOf, CODE_EXTENSIONS, IMAGE_EXTENSIONS } from "@/lib/file-helpers";
const cls = "h-4 w-4 shrink-0";
export function FileIcon({ name }: { name: string }) {
const ext = extOf(name);
if (ext === "md" || ext === "mdx") return <FileText className={`${cls} text-blue-500`} />;
if (ext === "json" || ext === "json5") return <FileJson2 className={`${cls} text-yellow-600`} />;
if (ext === "yaml" || ext === "yml" || ext === "toml") return <FileCog className={`${cls} text-orange-500`} />;
if (ext === "csv") return <FileSpreadsheet className={`${cls} text-green-600`} />;
if (ext === "sh" || ext === "bash" || ext === "zsh") return <FileTerminal className={`${cls} text-lime-600`} />;
if (IMAGE_EXTENSIONS.has(ext)) return <FileImage className={`${cls} text-emerald-500`} />;
if (ext === "mp4" || ext === "webm" || ext === "mov" || ext === "avi" || ext === "mkv") return <FileVideo className={`${cls} text-pink-500`} />;
if (ext === "mp3" || ext === "wav" || ext === "ogg" || ext === "flac" || ext === "m4a") return <FileAudio className={`${cls} text-orange-500`} />;
if (ext === "zip" || ext === "tar" || ext === "gz" || ext === "rar" || ext === "7z" || ext === "bz2") return <FileArchive className={`${cls} text-amber-600`} />;
if (ext === "ttf" || ext === "otf" || ext === "woff" || ext === "woff2") return <FileType className={`${cls} text-slate-500`} />;
if (ext === "env" || ext === "pem" || ext === "key" || ext === "crt") return <FileLock className={`${cls} text-red-500`} />;
if (CODE_EXTENSIONS.has(ext)) return <FileCode2 className={`${cls} text-orange-500`} />;
return <File className={`${cls} text-muted-foreground`} />;
}
+5 -104
View File
@@ -1,102 +1,13 @@
import { useState, useEffect, useCallback, useMemo } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import {
DndContext,
DragOverlay,
useDroppable,
useDraggable,
} from "@dnd-kit/core";
import {
Folder,
FolderOpen,
FileText,
FileCode2,
File,
FileImage,
FileJson2,
FileSpreadsheet,
FileTerminal,
FileArchive,
FileVideo,
FileAudio,
FileCog,
FileType,
FileLock,
ChevronRight,
Loader2,
Trash2,
} from "lucide-react";
import { extOf, CODE_EXTENSIONS, IMAGE_EXTENSIONS, formatSize, type TreeNode } from "@/lib/file-helpers";
import { DndContext, DragOverlay } from "@dnd-kit/core";
import { Folder, FolderOpen, ChevronRight, Loader2, Trash2 } from "lucide-react";
import { formatSize, type TreeNode } from "@/lib/file-helpers";
import { useTreeDnd } from "@/hooks/use-tree-dnd";
import { DragPreview } from "@/components/shared/drag-preview";
const cls = "h-4 w-4 shrink-0";
function FileIcon({ name }: { name: string }) {
const ext = extOf(name);
if (ext === "md" || ext === "mdx") return <FileText className={`${cls} text-blue-500`} />;
if (ext === "json" || ext === "json5") return <FileJson2 className={`${cls} text-yellow-600`} />;
if (ext === "yaml" || ext === "yml" || ext === "toml") return <FileCog className={`${cls} text-orange-500`} />;
if (ext === "csv") return <FileSpreadsheet className={`${cls} text-green-600`} />;
if (ext === "sh" || ext === "bash" || ext === "zsh") return <FileTerminal className={`${cls} text-lime-600`} />;
if (IMAGE_EXTENSIONS.has(ext)) return <FileImage className={`${cls} text-emerald-500`} />;
if (ext === "mp4" || ext === "webm" || ext === "mov" || ext === "avi" || ext === "mkv") return <FileVideo className={`${cls} text-pink-500`} />;
if (ext === "mp3" || ext === "wav" || ext === "ogg" || ext === "flac" || ext === "m4a") return <FileAudio className={`${cls} text-orange-500`} />;
if (ext === "zip" || ext === "tar" || ext === "gz" || ext === "rar" || ext === "7z" || ext === "bz2") return <FileArchive className={`${cls} text-amber-600`} />;
if (ext === "ttf" || ext === "otf" || ext === "woff" || ext === "woff2") return <FileType className={`${cls} text-slate-500`} />;
if (ext === "env" || ext === "pem" || ext === "key" || ext === "crt") return <FileLock className={`${cls} text-red-500`} />;
if (CODE_EXTENSIONS.has(ext)) return <FileCode2 className={`${cls} text-orange-500`} />;
return <File className={`${cls} text-muted-foreground`} />;
}
/** Draggable wrapper for tree items (files and folders). */
function DraggableItem({
id,
enabled,
children,
}: {
id: string;
enabled: boolean;
children: React.ReactNode;
}) {
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
id,
disabled: !enabled,
});
return (
<div
ref={setNodeRef}
{...(enabled ? { ...listeners, ...attributes } : {})}
className={isDragging ? "opacity-40" : ""}
>
{children}
</div>
);
}
/** Droppable wrapper for folder items. */
function DroppableFolder({
id,
enabled,
children,
}: {
id: string;
enabled: boolean;
children: (props: { isDropTarget: boolean }) => React.ReactNode;
}) {
const { setNodeRef, isOver } = useDroppable({
id,
disabled: !enabled,
});
return (
<div ref={setNodeRef}>
{children({ isDropTarget: isOver })}
</div>
);
}
import { FileIcon } from "./file-tree-file-icon";
import { DraggableItem, DroppableFolder, RootDropZone } from "./file-tree-dnd-wrappers";
export function TreeItem({
node,
@@ -346,13 +257,3 @@ export function FileTreePanel({
);
}
/** Root-level droppable zone — dropping here moves to root (""). */
function RootDropZone({ children }: { children: React.ReactNode }) {
const { setNodeRef, isOver } = useDroppable({ id: "__root__" });
return (
<div ref={setNodeRef} className={`min-h-full ${isOver ? "bg-primary/5" : ""}`}>
{children}
</div>
);
}
@@ -0,0 +1,239 @@
/**
* Internal viewer panels used by FileContentBody:
* - CodeViewer: syntax-highlighted code with copy button
* - CsvViewer: tabular CSV display with copy button
* - ImageViewer: blob-fetched image display
* - UnsupportedFileViewer: fallback with download button
*/
import { useMemo, useRef, useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Check, Copy, Download, FileQuestion, Loader2 } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { useClipboard } from "@/hooks/use-clipboard";
import { formatSize, sizeBadgeVariant } from "@/lib/file-helpers";
import hljs from "highlight.js/lib/core";
import typescript from "highlight.js/lib/languages/typescript";
import javascript from "highlight.js/lib/languages/javascript";
import python from "highlight.js/lib/languages/python";
import go from "highlight.js/lib/languages/go";
import bash from "highlight.js/lib/languages/bash";
import json from "highlight.js/lib/languages/json";
import yaml from "highlight.js/lib/languages/yaml";
import css from "highlight.js/lib/languages/css";
import xml from "highlight.js/lib/languages/xml";
import sql from "highlight.js/lib/languages/sql";
import rust from "highlight.js/lib/languages/rust";
import ruby from "highlight.js/lib/languages/ruby";
import java from "highlight.js/lib/languages/java";
import c from "highlight.js/lib/languages/c";
import cpp from "highlight.js/lib/languages/cpp";
import lua from "highlight.js/lib/languages/lua";
hljs.registerLanguage("typescript", typescript);
hljs.registerLanguage("tsx", typescript);
hljs.registerLanguage("javascript", javascript);
hljs.registerLanguage("jsx", javascript);
hljs.registerLanguage("python", python);
hljs.registerLanguage("go", go);
hljs.registerLanguage("bash", bash);
hljs.registerLanguage("json", json);
hljs.registerLanguage("yaml", yaml);
hljs.registerLanguage("css", css);
hljs.registerLanguage("html", xml);
hljs.registerLanguage("xml", xml);
hljs.registerLanguage("sql", sql);
hljs.registerLanguage("rust", rust);
hljs.registerLanguage("ruby", ruby);
hljs.registerLanguage("java", java);
hljs.registerLanguage("c", c);
hljs.registerLanguage("cpp", cpp);
hljs.registerLanguage("lua", lua);
export function CodeViewer({ content, language }: { content: string; language: string }) {
const { copied, copy } = useClipboard();
const { t } = useTranslation("common");
const codeRef = useRef<HTMLElement>(null);
const highlighted = useMemo(() => {
if (language && hljs.getLanguage(language)) {
try {
return hljs.highlight(content, { language }).value;
} catch { /* fallback */ }
}
return null;
}, [content, language]);
return (
<div className="group relative overflow-hidden rounded-lg border border-border/60">
<div className="flex items-center justify-between border-b border-border/40 bg-muted/70 px-3 py-1.5 text-[11px] font-medium tracking-wide text-muted-foreground uppercase">
<span>{language || "text"}</span>
<button
type="button"
onClick={() => copy(content)}
className="cursor-pointer opacity-0 transition-opacity group-hover:opacity-100"
title={t("copy")}
>
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
</button>
</div>
<pre className="overflow-auto bg-muted/30 p-4 text-[13px] leading-relaxed hljs">
{highlighted ? (
<code
ref={codeRef}
style={{ fontFamily: "'JetBrains Mono', 'Fira Code', ui-monospace, monospace" }}
dangerouslySetInnerHTML={{ __html: highlighted }}
/>
) : (
<code style={{ fontFamily: "'JetBrains Mono', 'Fira Code', ui-monospace, monospace" }}>
{content}
</code>
)}
</pre>
</div>
);
}
export function CsvViewer({ content }: { content: string }) {
const { copied, copy } = useClipboard();
const { t } = useTranslation("common");
const rows = useMemo(() => {
return content.split("\n").filter(Boolean).map((line) => {
const cols: string[] = [];
let cur = "";
let inQuote = false;
for (let i = 0; i < line.length; i++) {
const ch = line[i];
if (ch === '"') { inQuote = !inQuote; continue; }
if (ch === "," && !inQuote) { cols.push(cur.trim()); cur = ""; continue; }
cur += ch;
}
cols.push(cur.trim());
return cols;
});
}, [content]);
const header = rows[0];
if (!header || rows.length === 0) return <pre className="text-sm p-4">{content}</pre>;
const body = rows.slice(1);
return (
<div className="group relative rounded-lg border border-border/60 flex flex-col overflow-hidden">
<div className="flex items-center justify-between border-b border-border/40 bg-muted/70 px-3 py-1.5 text-[11px] font-medium tracking-wide text-muted-foreground uppercase shrink-0">
<span>{t("csvRows", { count: body.length })}</span>
<button
type="button"
onClick={() => copy(content)}
className="cursor-pointer opacity-0 transition-opacity group-hover:opacity-100"
title={t("copy")}
>
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
</button>
</div>
<div className="overflow-auto flex-1 min-h-0">
<table className="w-full text-[13px] border-collapse">
<thead className="sticky top-0 z-10">
<tr className="bg-muted/70">
{header.map((col, i) => (
<th key={i} className="px-3 py-2 text-left text-xs font-semibold tracking-wide border-b border-border/60 whitespace-nowrap">
{col}
</th>
))}
</tr>
</thead>
<tbody>
{body.map((row, i) => (
<tr key={i} className="border-b border-border/40 last:border-0 even:bg-muted/30 hover:bg-muted/50">
{header.map((_, j) => (
<td key={j} className="px-3 py-1.5 border-r border-border/30 last:border-r-0">
{row[j] ?? ""}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
export function ImageViewer({ path, fetchBlob }: { path: string; fetchBlob: (path: string) => Promise<Blob> }) {
const { t } = useTranslation("common");
const [src, setSrc] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
useEffect(() => {
let objectUrl: string | null = null;
setLoading(true);
setError(false);
fetchBlob(path)
.then((blob) => {
objectUrl = URL.createObjectURL(blob);
setSrc(objectUrl);
})
.catch(() => setError(true))
.finally(() => setLoading(false));
return () => {
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
}, [path, fetchBlob]);
if (loading) {
return (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
);
}
if (error || !src) {
return (
<div className="flex items-center justify-center py-12 text-sm text-muted-foreground">
{t("failedToLoadImage")}
</div>
);
}
return (
<div className="flex items-center justify-center p-4">
<img
src={src}
alt={path.split("/").pop() ?? ""}
className="max-w-full max-h-[70vh] object-contain rounded-lg border border-border/40"
/>
</div>
);
}
export function UnsupportedFileViewer({
path,
size,
onDownload,
}: {
path: string;
size: number;
onDownload?: () => void;
}) {
const { t } = useTranslation("storage");
const fileName = path.split("/").pop() ?? path;
return (
<div className="flex flex-col items-center justify-center py-16 gap-4">
<FileQuestion className="h-12 w-12 text-muted-foreground/50" />
<p className="text-sm text-muted-foreground">{t("unsupportedFile")}</p>
{onDownload && (
<Button variant="outline" size="sm" onClick={onDownload}>
<Download className="h-3.5 w-3.5 mr-1.5" />
{fileName}
<Badge variant={sizeBadgeVariant(size)} className="text-[10px] ml-1.5">
{formatSize(size)}
</Badge>
</Button>
)}
</div>
);
}
+13 -226
View File
@@ -1,239 +1,26 @@
import { useMemo, useRef, useState, useEffect } from "react";
/**
* FileContentBody and FileContentPanel — smart file viewers that route
* to the correct viewer based on file type.
*
* Viewer panel implementations live in file-viewer-panels.tsx.
*/
import { useTranslation } from "react-i18next";
import { Check, Copy, Download, FileQuestion, Loader2 } from "lucide-react";
import { Loader2 } from "lucide-react";
import { MarkdownRenderer } from "@/components/shared/markdown-renderer";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { useClipboard } from "@/hooks/use-clipboard";
import hljs from "highlight.js/lib/core";
import typescript from "highlight.js/lib/languages/typescript";
import javascript from "highlight.js/lib/languages/javascript";
import python from "highlight.js/lib/languages/python";
import go from "highlight.js/lib/languages/go";
import bash from "highlight.js/lib/languages/bash";
import json from "highlight.js/lib/languages/json";
import yaml from "highlight.js/lib/languages/yaml";
import css from "highlight.js/lib/languages/css";
import xml from "highlight.js/lib/languages/xml";
import sql from "highlight.js/lib/languages/sql";
import rust from "highlight.js/lib/languages/rust";
import ruby from "highlight.js/lib/languages/ruby";
import java from "highlight.js/lib/languages/java";
import c from "highlight.js/lib/languages/c";
import cpp from "highlight.js/lib/languages/cpp";
import lua from "highlight.js/lib/languages/lua";
import {
extOf,
langFor,
stripFrontmatter,
formatSize,
sizeBadgeVariant,
isImageFile,
isTextFile,
CODE_EXTENSIONS,
} from "@/lib/file-helpers";
hljs.registerLanguage("typescript", typescript);
hljs.registerLanguage("tsx", typescript);
hljs.registerLanguage("javascript", javascript);
hljs.registerLanguage("jsx", javascript);
hljs.registerLanguage("python", python);
hljs.registerLanguage("go", go);
hljs.registerLanguage("bash", bash);
hljs.registerLanguage("json", json);
hljs.registerLanguage("yaml", yaml);
hljs.registerLanguage("css", css);
hljs.registerLanguage("html", xml);
hljs.registerLanguage("xml", xml);
hljs.registerLanguage("sql", sql);
hljs.registerLanguage("rust", rust);
hljs.registerLanguage("ruby", ruby);
hljs.registerLanguage("java", java);
hljs.registerLanguage("c", c);
hljs.registerLanguage("cpp", cpp);
hljs.registerLanguage("lua", lua);
function CodeViewer({ content, language }: { content: string; language: string }) {
const { copied, copy } = useClipboard();
const { t } = useTranslation("common");
const codeRef = useRef<HTMLElement>(null);
const highlighted = useMemo(() => {
if (language && hljs.getLanguage(language)) {
try {
return hljs.highlight(content, { language }).value;
} catch { /* fallback */ }
}
return null;
}, [content, language]);
return (
<div className="group relative overflow-hidden rounded-lg border border-border/60">
<div className="flex items-center justify-between border-b border-border/40 bg-muted/70 px-3 py-1.5 text-[11px] font-medium tracking-wide text-muted-foreground uppercase">
<span>{language || "text"}</span>
<button
type="button"
onClick={() => copy(content)}
className="cursor-pointer opacity-0 transition-opacity group-hover:opacity-100"
title={t("copy")}
>
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
</button>
</div>
<pre className="overflow-auto bg-muted/30 p-4 text-[13px] leading-relaxed hljs">
{highlighted ? (
<code ref={codeRef} style={{ fontFamily: "'JetBrains Mono', 'Fira Code', ui-monospace, monospace" }} dangerouslySetInnerHTML={{ __html: highlighted }} />
) : (
<code style={{ fontFamily: "'JetBrains Mono', 'Fira Code', ui-monospace, monospace" }}>{content}</code>
)}
</pre>
</div>
);
}
function CsvViewer({ content }: { content: string }) {
const { copied, copy } = useClipboard();
const { t } = useTranslation("common");
const rows = useMemo(() => {
return content.split("\n").filter(Boolean).map((line) => {
const cols: string[] = [];
let cur = "";
let inQuote = false;
for (let i = 0; i < line.length; i++) {
const ch = line[i];
if (ch === '"') { inQuote = !inQuote; continue; }
if (ch === "," && !inQuote) { cols.push(cur.trim()); cur = ""; continue; }
cur += ch;
}
cols.push(cur.trim());
return cols;
});
}, [content]);
const header = rows[0];
if (!header || rows.length === 0) return <pre className="text-sm p-4">{content}</pre>;
const body = rows.slice(1);
return (
<div className="group relative rounded-lg border border-border/60 flex flex-col overflow-hidden">
<div className="flex items-center justify-between border-b border-border/40 bg-muted/70 px-3 py-1.5 text-[11px] font-medium tracking-wide text-muted-foreground uppercase shrink-0">
<span>{t("csvRows", { count: body.length })}</span>
<button
type="button"
onClick={() => copy(content)}
className="cursor-pointer opacity-0 transition-opacity group-hover:opacity-100"
title={t("copy")}
>
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
</button>
</div>
<div className="overflow-auto flex-1 min-h-0">
<table className="w-full text-[13px] border-collapse">
<thead className="sticky top-0 z-10">
<tr className="bg-muted/70">
{header.map((col, i) => (
<th key={i} className="px-3 py-2 text-left text-xs font-semibold tracking-wide border-b border-border/60 whitespace-nowrap">
{col}
</th>
))}
</tr>
</thead>
<tbody>
{body.map((row, i) => (
<tr key={i} className="border-b border-border/40 last:border-0 even:bg-muted/30 hover:bg-muted/50">
{header.map((_, j) => (
<td key={j} className="px-3 py-1.5 border-r border-border/30 last:border-r-0">
{row[j] ?? ""}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
function ImageViewer({ path, fetchBlob }: { path: string; fetchBlob: (path: string) => Promise<Blob> }) {
const { t } = useTranslation("common");
const [src, setSrc] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
useEffect(() => {
let objectUrl: string | null = null;
setLoading(true);
setError(false);
fetchBlob(path)
.then((blob) => {
objectUrl = URL.createObjectURL(blob);
setSrc(objectUrl);
})
.catch(() => setError(true))
.finally(() => setLoading(false));
return () => {
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
}, [path, fetchBlob]);
if (loading) {
return (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
);
}
if (error || !src) {
return (
<div className="flex items-center justify-center py-12 text-sm text-muted-foreground">
{t("failedToLoadImage")}
</div>
);
}
return (
<div className="flex items-center justify-center p-4">
<img
src={src}
alt={path.split("/").pop() ?? ""}
className="max-w-full max-h-[70vh] object-contain rounded-lg border border-border/40"
/>
</div>
);
}
function UnsupportedFileViewer({
path,
size,
onDownload,
}: {
path: string;
size: number;
onDownload?: () => void;
}) {
const { t } = useTranslation("storage");
const fileName = path.split("/").pop() ?? path;
return (
<div className="flex flex-col items-center justify-center py-16 gap-4">
<FileQuestion className="h-12 w-12 text-muted-foreground/50" />
<p className="text-sm text-muted-foreground">{t("unsupportedFile")}</p>
{onDownload && (
<Button variant="outline" size="sm" onClick={onDownload}>
<Download className="h-3.5 w-3.5 mr-1.5" />
{fileName}
<Badge variant={sizeBadgeVariant(size)} className="text-[10px] ml-1.5">
{formatSize(size)}
</Badge>
</Button>
)}
</div>
);
}
import {
CodeViewer,
CsvViewer,
ImageViewer,
UnsupportedFileViewer,
} from "./file-viewer-panels";
export function FileContentBody({
path,
@@ -0,0 +1,61 @@
/**
* CachedMarkdownImg — image component for MarkdownRenderer that:
* - Resolves /v1/files/ URLs via useMediaUrl cache
* - Opens lightbox on click
* - Shows a download button on hover
*/
import { Download } from "lucide-react";
import { toFileUrl, toDownloadUrl } from "@/lib/file-helpers";
import { useMediaUrl } from "@/hooks/use-media-url";
function fileNameFromHref(href: string): string {
const path = href.split("?")[0] ?? href;
const segments = path.split("/");
return segments[segments.length - 1] ?? "file";
}
export function CachedMarkdownImg({
src,
alt,
openLightbox,
...props
}: {
src?: string;
alt?: string;
openLightbox: (src: string, alt: string) => void;
[key: string]: unknown;
}) {
const isFileLink = src
? src.startsWith("/v1/files/") || src.includes("/v1/files/")
: false;
const resolvedSrc = isFileLink ? toFileUrl(src!) : src;
const cachedSrc = useMediaUrl(resolvedSrc);
const displayName = alt || fileNameFromHref(src ?? "");
return (
<span className="group/img relative inline-block overflow-hidden rounded-lg border shadow-sm">
<img
src={cachedSrc}
alt={alt ?? "image"}
className="block max-w-sm cursor-pointer hover:opacity-90 transition-opacity"
loading="lazy"
onClick={(e: React.MouseEvent) => {
e.preventDefault();
if (resolvedSrc) openLightbox(resolvedSrc, alt ?? "image");
}}
{...props}
/>
{resolvedSrc && (
<a
href={toDownloadUrl(resolvedSrc)}
download={displayName}
onClick={(e: React.MouseEvent) => e.stopPropagation()}
className="absolute top-2 right-2 flex items-center justify-center rounded-lg bg-white/90 dark:bg-neutral-800/90 p-1.5 text-neutral-700 dark:text-neutral-200 shadow-md ring-1 ring-black/10 dark:ring-white/10 opacity-0 transition-opacity group-hover/img:opacity-100 hover:bg-white dark:hover:bg-neutral-700 cursor-pointer"
title="Download"
>
<Download className="h-4.5 w-4.5" />
</a>
)}
</span>
);
}
@@ -0,0 +1,48 @@
/**
* CodeBlock component for MarkdownRenderer — syntax-highlighted code block
* with a copy button and language label in the header bar.
*/
import { useTranslation } from "react-i18next";
import { Check, Copy } from "lucide-react";
import { useClipboard } from "@/hooks/use-clipboard";
export function CodeBlock({
className,
children,
}: {
className?: string;
children?: React.ReactNode;
}) {
const { copied, copy } = useClipboard();
const { t } = useTranslation("common");
const text = String(children).replace(/\n$/, "");
const lang = className?.replace("language-", "") ?? "";
return (
<div className="not-prose group relative my-3 overflow-hidden rounded-lg border border-border/60">
<div className="flex items-center justify-between border-b border-border/40 bg-muted/70 px-3 py-1.5 text-[11px] font-medium tracking-wide text-muted-foreground uppercase">
<span>{lang || "code"}</span>
<button
type="button"
onClick={() => copy(text)}
className="cursor-pointer opacity-0 transition-opacity group-hover:opacity-100"
title={t("copyCode")}
>
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
</button>
</div>
<pre className="overflow-x-auto bg-muted/30 p-3 text-[13px] leading-normal text-foreground whitespace-pre">
<code
className={className}
style={{
fontFamily: "'JetBrains Mono', 'Fira Code', ui-monospace, monospace",
wordWrap: "normal",
overflowWrap: "normal",
}}
>
{children}
</code>
</pre>
</div>
);
}
@@ -1,5 +1,4 @@
import { useState, useCallback, useEffect, useRef, useMemo, memo } from "react";
import { useTranslation } from "react-i18next";
import ReactMarkdown, { type Components } from "react-markdown";
import remarkGfm from "remark-gfm";
import rehypeHighlight from "rehype-highlight";
@@ -7,10 +6,8 @@ import rehypeHighlight from "rehype-highlight";
// Stable plugin arrays — avoids new references on every render
const remarkPlugins = [remarkGfm];
const rehypePlugins = [rehypeHighlight];
import { useClipboard } from "@/hooks/use-clipboard";
import { toFileUrl, toDownloadUrl } from "@/lib/file-helpers";
import { useMediaUrl } from "@/hooks/use-media-url";
import { Check, Copy, Download, FileText } from "lucide-react";
import { Download, FileText } from "lucide-react";
import { ImageLightbox } from "./image-lightbox";
import { useChatImageGallery } from "@/components/chat/chat-image-gallery-context";
import {
@@ -19,38 +16,8 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
function CodeBlock({
className,
children,
}: {
className?: string;
children?: React.ReactNode;
}) {
const { copied, copy } = useClipboard();
const { t } = useTranslation("common");
const text = String(children).replace(/\n$/, "");
const lang = className?.replace("language-", "") ?? "";
return (
<div className="not-prose group relative my-3 overflow-hidden rounded-lg border border-border/60">
<div className="flex items-center justify-between border-b border-border/40 bg-muted/70 px-3 py-1.5 text-[11px] font-medium tracking-wide text-muted-foreground uppercase">
<span>{lang || "code"}</span>
<button
type="button"
onClick={() => copy(text)}
className="cursor-pointer opacity-0 transition-opacity group-hover:opacity-100"
title={t("copyCode")}
>
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
</button>
</div>
<pre className="overflow-x-auto bg-muted/30 p-3 text-[13px] leading-normal text-foreground whitespace-pre">
<code className={className} style={{ fontFamily: "'JetBrains Mono', 'Fira Code', ui-monospace, monospace", wordWrap: "normal", overflowWrap: "normal" }}>{children}</code>
</pre>
</div>
);
}
import { CodeBlock } from "./markdown-code-block";
import { CachedMarkdownImg } from "./markdown-cached-img";
interface MarkdownRendererProps {
content: string;
@@ -87,43 +54,6 @@ function fileNameFromHref(href: string): string {
return segments[segments.length - 1] ?? "file";
}
/** Extracted from useMemo components so useMediaUrl hook is valid. */
function CachedMarkdownImg({ src, alt, openLightbox, ...props }: {
src?: string; alt?: string;
openLightbox: (src: string, alt: string) => void;
[key: string]: unknown;
}) {
const resolvedSrc = isFileLink(src) ? toFileUrl(src!) : src;
const cachedSrc = useMediaUrl(resolvedSrc);
const displayName = alt || fileNameFromHref(src ?? "");
return (
<span className="group/img relative inline-block overflow-hidden rounded-lg border shadow-sm">
<img
src={cachedSrc}
alt={alt ?? "image"}
className="block max-w-sm cursor-pointer hover:opacity-90 transition-opacity"
loading="lazy"
onClick={(e: React.MouseEvent) => {
e.preventDefault();
if (resolvedSrc) openLightbox(resolvedSrc, alt ?? "image");
}}
{...props}
/>
{resolvedSrc && (
<a
href={toDownloadUrl(resolvedSrc)}
download={displayName}
onClick={(e: React.MouseEvent) => e.stopPropagation()}
className="absolute top-2 right-2 flex items-center justify-center rounded-lg bg-white/90 dark:bg-neutral-800/90 p-1.5 text-neutral-700 dark:text-neutral-200 shadow-md ring-1 ring-black/10 dark:ring-white/10 opacity-0 transition-opacity group-hover/img:opacity-100 hover:bg-white dark:hover:bg-neutral-700 cursor-pointer"
title="Download"
>
<Download className="h-4.5 w-4.5" />
</a>
)}
</span>
);
}
export const MarkdownRenderer = memo(function MarkdownRenderer({ content, className }: MarkdownRendererProps) {
const gallery = useChatImageGallery();
const [lightbox, setLightbox] = useState<{ src: string; alt: string } | null>(null);
@@ -0,0 +1,86 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { ChevronDown } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
interface HeartbeatAdvancedPanelProps {
ackMaxChars: number;
setAckMaxChars: (v: number) => void;
maxRetries: number;
setMaxRetries: (v: number) => void;
isolatedSession: boolean;
setIsolatedSession: (v: boolean) => void;
lightContext: boolean;
setLightContext: (v: boolean) => void;
}
/** Collapsible advanced settings panel for the heartbeat config dialog. */
export function HeartbeatAdvancedPanel({
ackMaxChars, setAckMaxChars,
maxRetries, setMaxRetries,
isolatedSession, setIsolatedSession,
lightContext, setLightContext,
}: HeartbeatAdvancedPanelProps) {
const { t } = useTranslation("agents");
const [showAdvanced, setShowAdvanced] = useState(false);
return (
<div className="rounded-lg border">
<button
type="button"
onClick={() => setShowAdvanced(!showAdvanced)}
className="flex w-full items-center justify-between px-3 py-2 text-xs font-medium text-muted-foreground hover:text-foreground transition-colors"
>
<span>{t("heartbeat.advancedSettings")}</span>
<ChevronDown className={`h-3.5 w-3.5 transition-transform ${showAdvanced ? "rotate-180" : ""}`} />
</button>
{showAdvanced && (
<div className="border-t px-3 py-3 space-y-3">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div className="space-y-1">
<Label htmlFor="hb-ack" className="text-xs">{t("heartbeat.ackMaxChars")}</Label>
<Input
id="hb-ack"
type="number"
min={0}
value={ackMaxChars}
onChange={(e) => setAckMaxChars(Number(e.target.value))}
className="text-base md:text-sm"
/>
<p className="text-[11px] text-muted-foreground">{t("heartbeat.ackMaxCharsHint")}</p>
</div>
<div className="space-y-1">
<Label htmlFor="hb-retries" className="text-xs">{t("heartbeat.maxRetries")}</Label>
<Input
id="hb-retries"
type="number"
min={0}
max={10}
value={maxRetries}
onChange={(e) => setMaxRetries(Number(e.target.value))}
className="text-base md:text-sm"
/>
<p className="text-[11px] text-muted-foreground">{t("heartbeat.maxRetriesHint")}</p>
</div>
</div>
<div className="flex items-center justify-between gap-4">
<div>
<span className="text-xs font-medium">{t("heartbeat.isolatedSession")}</span>
<p className="text-[11px] text-muted-foreground">{t("heartbeat.isolatedSessionHint")}</p>
</div>
<Switch checked={isolatedSession} onCheckedChange={setIsolatedSession} />
</div>
<div className="flex items-center justify-between gap-4">
<div>
<span className="text-xs font-medium">{t("heartbeat.lightContext")}</span>
<p className="text-[11px] text-muted-foreground">{t("heartbeat.lightContextHint")}</p>
</div>
<Switch checked={lightContext} onCheckedChange={setLightContext} />
</div>
</div>
)}
</div>
);
}
@@ -1,27 +1,25 @@
import { useState, useEffect, useCallback, useMemo } from "react";
import { Play, Loader2, ChevronDown, Heart, Clock, Send, FileText, Cpu } from "lucide-react";
import { Play, Loader2, Heart, Clock, FileText, Cpu } from "lucide-react";
import { useTranslation } from "react-i18next";
import {
Dialog, DialogContent, DialogHeader, DialogTitle,
} from "@/components/ui/dialog";
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 { Textarea } from "@/components/ui/textarea";
import { Badge } from "@/components/ui/badge";
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "@/components/ui/select";
import { useMinLoading } from "@/hooks/use-min-loading";
import { useChannels } from "@/pages/channels/hooks/use-channels";
import { useProviders } from "@/pages/providers/hooks/use-providers";
import { useUiStore } from "@/stores/use-ui-store";
import { ProviderModelSelect } from "@/components/shared/provider-model-select";
import { Combobox } from "@/components/ui/combobox";
import { getAllIanaTimezones, isValidIanaTimezone } from "@/lib/constants";
import { isValidIanaTimezone } from "@/lib/constants";
import { toast } from "@/stores/use-toast-store";
import type { HeartbeatConfig, DeliveryTarget } from "@/pages/agents/hooks/use-agent-heartbeat";
import { HeartbeatScheduleSection } from "./heartbeat-schedule-section";
import { HeartbeatAdvancedPanel } from "./heartbeat-advanced-panel";
import { HeartbeatDeliverySection } from "./heartbeat-delivery-section";
interface HeartbeatConfigDialogProps {
open: boolean;
@@ -34,9 +32,7 @@ interface HeartbeatConfigDialogProps {
setChecklist: (content: string) => Promise<void>;
fetchTargets: () => Promise<DeliveryTarget[]>;
refresh: () => Promise<void>;
/** Agent's current provider name (used as default for heartbeat). */
agentProvider?: string;
/** Agent's current model (used as default for heartbeat). */
agentModel?: string;
}
@@ -52,7 +48,6 @@ export function HeartbeatConfigDialog({
const browserTz = Intl.DateTimeFormat().resolvedOptions().timeZone;
const defaultTz = userTz && userTz !== "auto" ? userTz : browserTz;
// Form state
const [enabled, setEnabled] = useState(false);
const [intervalMin, setIntervalMin] = useState(30);
const [ackMaxChars, setAckMaxChars] = useState(300);
@@ -69,12 +64,16 @@ export function HeartbeatConfigDialog({
const [checklist, setChecklistState] = useState("");
const [originalChecklist, setOriginalChecklist] = useState("");
const [checklistLoading, setChecklistLoading] = useState(false);
const [showAdvanced, setShowAdvanced] = useState(false);
const [targets, setTargets] = useState<DeliveryTarget[]>([]);
const [testRunning, setTestRunning] = useState(false);
const showTestSpin = useMinLoading(testRunning, 600);
const providerNameById = useMemo(() => {
const map: Record<string, string> = {};
for (const p of providers) map[p.id] = p.name;
return map;
}, [providers]);
const loadChecklist = useCallback(async () => {
setChecklistLoading(true);
try {
@@ -86,14 +85,6 @@ export function HeartbeatConfigDialog({
}
}, [getChecklist]);
// Map providerId UUID → provider name for the select component.
const providerNameById = useMemo(() => {
const map: Record<string, string> = {};
for (const p of providers) map[p.id] = p.name;
return map;
}, [providers]);
// Sync form state only when dialog opens (false→true).
useEffect(() => {
if (!open) return;
if (config) {
@@ -108,16 +99,13 @@ export function HeartbeatConfigDialog({
setTimezone(config.timezone || defaultTz);
setChannel(config.channel ?? "");
setChatId(config.chatId ?? "");
// Map stored providerId UUID → name for the select
setHbProvider(config.providerId ? (providerNameById[config.providerId] ?? "") : "");
setHbModel(config.model ?? "");
} else {
// First-time setup defaults
setTimezone(defaultTz);
setHbProvider("");
setHbModel("");
}
setShowAdvanced(false);
loadChecklist();
fetchTargets().then(setTargets).catch(() => {});
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -138,10 +126,10 @@ export function HeartbeatConfigDialog({
await update({
enabled,
intervalSec: clampedMin * 60,
ackMaxChars: ackMaxChars,
maxRetries: maxRetries,
isolatedSession: isolatedSession,
lightContext: lightContext,
ackMaxChars,
maxRetries,
isolatedSession,
lightContext,
activeHoursStart: activeHoursStart || undefined,
activeHoursEnd: activeHoursEnd || undefined,
timezone: timezone || undefined,
@@ -174,10 +162,9 @@ export function HeartbeatConfigDialog({
</DialogTitle>
</DialogHeader>
{/* Scrollable body — standard pattern: -mx-4 px-4 */}
<div className="overflow-y-auto min-h-0 -mx-4 px-4 sm:-mx-6 sm:px-6 space-y-4 overscroll-contain">
{/* ── Enable + Interval (top priority) ── */}
{/* Enable + Interval */}
<div className="flex items-center justify-between gap-4 rounded-lg border p-3">
<div className="flex items-center gap-3 min-w-0">
<Switch checked={enabled} onCheckedChange={setEnabled} />
@@ -199,7 +186,7 @@ export function HeartbeatConfigDialog({
</div>
</div>
{/* ── Provider / Model override ── */}
{/* Provider / Model override */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<Cpu className="h-3.5 w-3.5 text-orange-500" />
@@ -220,188 +207,28 @@ export function HeartbeatConfigDialog({
/>
</div>
{/* ── Delivery — WHERE it sends ── */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<Send className="h-3.5 w-3.5 text-blue-500" />
<h4 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{t("heartbeat.sectionDelivery")}
</h4>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-[140px_1fr]">
<div className="space-y-1">
<Label className="text-xs">{t("heartbeat.channel")}</Label>
{channelNames.length > 0 ? (
<Select
value={channel || "__none__"}
onValueChange={(v) => { setChannel(v === "__none__" ? "" : v); setChatId(""); }}
>
<SelectTrigger className="text-base md:text-sm">
<SelectValue placeholder={t("heartbeat.channelPlaceholder")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">{t("heartbeat.channelNone")}</SelectItem>
{channelNames.map((ch) => (
<SelectItem key={ch} value={ch}>{ch}</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
placeholder="telegram"
value={channel}
onChange={(e) => setChannel(e.target.value)}
className="text-base md:text-sm"
/>
)}
</div>
<div className="space-y-1">
<Label className="text-xs">{t("heartbeat.chatId")}</Label>
{(() => {
if (!channel) {
return (
<Input
placeholder={t("heartbeat.selectChannelFirst")}
disabled
className="text-base md:text-sm"
/>
);
}
const filtered = targets.filter((t) => t.channel === channel);
if (filtered.length > 0) {
return (
<Select
value={chatId || "__none__"}
onValueChange={(v) => setChatId(v === "__none__" ? "" : v)}
>
<SelectTrigger className="text-base md:text-sm">
<SelectValue placeholder={t("heartbeat.chatIdPlaceholder")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">{t("heartbeat.channelNone")}</SelectItem>
{filtered.map((tgt) => (
<SelectItem key={tgt.chatId} value={tgt.chatId}>
{tgt.title ? `${tgt.title} (${tgt.chatId})` : tgt.chatId}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
return (
<Input
placeholder="-100123456789"
value={chatId}
onChange={(e) => setChatId(e.target.value)}
className="text-base md:text-sm"
/>
);
})()}
</div>
</div>
</div>
<HeartbeatDeliverySection
channelNames={channelNames}
channel={channel} setChannel={setChannel}
chatId={chatId} setChatId={setChatId}
targets={targets}
/>
{/* ── Schedule — WHEN it runs ── */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<Clock className="h-3.5 w-3.5 text-amber-500" />
<h4 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{t("heartbeat.sectionSchedule")}
</h4>
</div>
<div className="flex flex-wrap items-end gap-3">
<div className="space-y-1 w-24">
<Label htmlFor="hb-start" className="text-xs">{t("heartbeat.activeHoursStart")}</Label>
<Input
id="hb-start"
placeholder="08:00"
value={activeHoursStart}
onChange={(e) => setActiveHoursStart(e.target.value)}
className="text-base md:text-sm"
/>
</div>
<div className="space-y-1 w-24">
<Label htmlFor="hb-end" className="text-xs">{t("heartbeat.activeHoursEnd")}</Label>
<Input
id="hb-end"
placeholder="22:00"
value={activeHoursEnd}
onChange={(e) => setActiveHoursEnd(e.target.value)}
className="text-base md:text-sm"
/>
</div>
<div className="space-y-1 flex-1 min-w-[160px]">
<Label className="text-xs">{t("heartbeat.timezone")}</Label>
<Combobox
value={timezone || "__auto__"}
onChange={(v) => setTimezone(v === "__auto__" ? "" : v)}
options={[{ value: "__auto__", label: defaultTz }, ...getAllIanaTimezones()]}
placeholder={t("heartbeat.timezone")}
className="text-base md:text-sm"
/>
</div>
</div>
<p className="text-xs text-muted-foreground">{t("heartbeat.scheduleHint")}</p>
</div>
<HeartbeatScheduleSection
activeHoursStart={activeHoursStart} setActiveHoursStart={setActiveHoursStart}
activeHoursEnd={activeHoursEnd} setActiveHoursEnd={setActiveHoursEnd}
timezone={timezone} setTimezone={setTimezone}
defaultTz={defaultTz}
/>
{/* ── Advanced (collapsible) ── */}
<div className="rounded-lg border">
<button
type="button"
onClick={() => setShowAdvanced(!showAdvanced)}
className="flex w-full items-center justify-between px-3 py-2 text-xs font-medium text-muted-foreground hover:text-foreground transition-colors"
>
<span>{t("heartbeat.advancedSettings")}</span>
<ChevronDown className={`h-3.5 w-3.5 transition-transform ${showAdvanced ? "rotate-180" : ""}`} />
</button>
{showAdvanced && (
<div className="border-t px-3 py-3 space-y-3">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div className="space-y-1">
<Label htmlFor="hb-ack" className="text-xs">{t("heartbeat.ackMaxChars")}</Label>
<Input
id="hb-ack"
type="number"
min={0}
value={ackMaxChars}
onChange={(e) => setAckMaxChars(Number(e.target.value))}
className="text-base md:text-sm"
/>
<p className="text-[11px] text-muted-foreground">{t("heartbeat.ackMaxCharsHint")}</p>
</div>
<div className="space-y-1">
<Label htmlFor="hb-retries" className="text-xs">{t("heartbeat.maxRetries")}</Label>
<Input
id="hb-retries"
type="number"
min={0}
max={10}
value={maxRetries}
onChange={(e) => setMaxRetries(Number(e.target.value))}
className="text-base md:text-sm"
/>
<p className="text-[11px] text-muted-foreground">{t("heartbeat.maxRetriesHint")}</p>
</div>
</div>
<div className="flex items-center justify-between gap-4">
<div>
<span className="text-xs font-medium">{t("heartbeat.isolatedSession")}</span>
<p className="text-[11px] text-muted-foreground">{t("heartbeat.isolatedSessionHint")}</p>
</div>
<Switch checked={isolatedSession} onCheckedChange={setIsolatedSession} />
</div>
<div className="flex items-center justify-between gap-4">
<div>
<span className="text-xs font-medium">{t("heartbeat.lightContext")}</span>
<p className="text-[11px] text-muted-foreground">{t("heartbeat.lightContextHint")}</p>
</div>
<Switch checked={lightContext} onCheckedChange={setLightContext} />
</div>
</div>
)}
</div>
<HeartbeatAdvancedPanel
ackMaxChars={ackMaxChars} setAckMaxChars={setAckMaxChars}
maxRetries={maxRetries} setMaxRetries={setMaxRetries}
isolatedSession={isolatedSession} setIsolatedSession={setIsolatedSession}
lightContext={lightContext} setLightContext={setLightContext}
/>
{/* ── Checklist — WHAT the agent does ── */}
{/* Checklist */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<FileText className="h-3.5 w-3.5 text-emerald-500" />
@@ -426,22 +253,18 @@ export function HeartbeatConfigDialog({
)}
</div>
{/* Bottom padding for scroll */}
<div className="h-1" />
</div>
{/* Footer — fixed at bottom */}
{/* Footer */}
<div className="flex items-center justify-between gap-2 border-t pt-3">
<Button
variant="outline"
size="sm"
variant="outline" size="sm"
onClick={handleTest}
disabled={showTestSpin || saving}
className="gap-1.5"
>
{showTestSpin
? <Loader2 className="h-3.5 w-3.5 animate-spin" />
: <Play className="h-3.5 w-3.5" />}
{showTestSpin ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Play className="h-3.5 w-3.5" />}
{t("heartbeat.testRun")}
</Button>
<div className="flex gap-2">
@@ -0,0 +1,100 @@
import { useTranslation } from "react-i18next";
import { Send } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "@/components/ui/select";
import type { DeliveryTarget } from "@/pages/agents/hooks/use-agent-heartbeat";
interface HeartbeatDeliverySectionProps {
channelNames: string[];
channel: string;
setChannel: (v: string) => void;
chatId: string;
setChatId: (v: string) => void;
targets: DeliveryTarget[];
}
/** Channel and chat-ID selectors for heartbeat delivery destination. */
export function HeartbeatDeliverySection({
channelNames,
channel, setChannel,
chatId, setChatId,
targets,
}: HeartbeatDeliverySectionProps) {
const { t } = useTranslation("agents");
return (
<div className="space-y-2">
<div className="flex items-center gap-2">
<Send className="h-3.5 w-3.5 text-blue-500" />
<h4 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{t("heartbeat.sectionDelivery")}
</h4>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-[140px_1fr]">
<div className="space-y-1">
<Label className="text-xs">{t("heartbeat.channel")}</Label>
{channelNames.length > 0 ? (
<Select
value={channel || "__none__"}
onValueChange={(v) => { setChannel(v === "__none__" ? "" : v); setChatId(""); }}
>
<SelectTrigger className="text-base md:text-sm">
<SelectValue placeholder={t("heartbeat.channelPlaceholder")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">{t("heartbeat.channelNone")}</SelectItem>
{channelNames.map((ch) => (
<SelectItem key={ch} value={ch}>{ch}</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
placeholder="telegram"
value={channel}
onChange={(e) => setChannel(e.target.value)}
className="text-base md:text-sm"
/>
)}
</div>
<div className="space-y-1">
<Label className="text-xs">{t("heartbeat.chatId")}</Label>
{(() => {
if (!channel) {
return <Input placeholder={t("heartbeat.selectChannelFirst")} disabled className="text-base md:text-sm" />;
}
const filtered = targets.filter((tgt) => tgt.channel === channel);
if (filtered.length > 0) {
return (
<Select value={chatId || "__none__"} onValueChange={(v) => setChatId(v === "__none__" ? "" : v)}>
<SelectTrigger className="text-base md:text-sm">
<SelectValue placeholder={t("heartbeat.chatIdPlaceholder")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">{t("heartbeat.channelNone")}</SelectItem>
{filtered.map((tgt) => (
<SelectItem key={tgt.chatId} value={tgt.chatId}>
{tgt.title ? `${tgt.title} (${tgt.chatId})` : tgt.chatId}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
return (
<Input
placeholder="-100123456789"
value={chatId}
onChange={(e) => setChatId(e.target.value)}
className="text-base md:text-sm"
/>
);
})()}
</div>
</div>
</div>
);
}
@@ -0,0 +1,70 @@
import { useTranslation } from "react-i18next";
import { Clock } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Combobox } from "@/components/ui/combobox";
import { getAllIanaTimezones } from "@/lib/constants";
interface HeartbeatScheduleSectionProps {
activeHoursStart: string;
setActiveHoursStart: (v: string) => void;
activeHoursEnd: string;
setActiveHoursEnd: (v: string) => void;
timezone: string;
setTimezone: (v: string) => void;
defaultTz: string;
}
/** Active-hours and timezone controls for the heartbeat schedule. */
export function HeartbeatScheduleSection({
activeHoursStart, setActiveHoursStart,
activeHoursEnd, setActiveHoursEnd,
timezone, setTimezone,
defaultTz,
}: HeartbeatScheduleSectionProps) {
const { t } = useTranslation("agents");
return (
<div className="space-y-2">
<div className="flex items-center gap-2">
<Clock className="h-3.5 w-3.5 text-amber-500" />
<h4 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{t("heartbeat.sectionSchedule")}
</h4>
</div>
<div className="flex flex-wrap items-end gap-3">
<div className="space-y-1 w-24">
<Label htmlFor="hb-start" className="text-xs">{t("heartbeat.activeHoursStart")}</Label>
<Input
id="hb-start"
placeholder="08:00"
value={activeHoursStart}
onChange={(e) => setActiveHoursStart(e.target.value)}
className="text-base md:text-sm"
/>
</div>
<div className="space-y-1 w-24">
<Label htmlFor="hb-end" className="text-xs">{t("heartbeat.activeHoursEnd")}</Label>
<Input
id="hb-end"
placeholder="22:00"
value={activeHoursEnd}
onChange={(e) => setActiveHoursEnd(e.target.value)}
className="text-base md:text-sm"
/>
</div>
<div className="space-y-1 flex-1 min-w-[160px]">
<Label className="text-xs">{t("heartbeat.timezone")}</Label>
<Combobox
value={timezone || "__auto__"}
onChange={(v) => setTimezone(v === "__auto__" ? "" : v)}
options={[{ value: "__auto__", label: defaultTz }, ...getAllIanaTimezones()]}
placeholder={t("heartbeat.timezone")}
className="text-base md:text-sm"
/>
</div>
</div>
<p className="text-xs text-muted-foreground">{t("heartbeat.scheduleHint")}</p>
</div>
);
}
@@ -0,0 +1,49 @@
import { useTranslation } from "react-i18next";
import { Badge } from "@/components/ui/badge";
import type { BuiltinToolData } from "./hooks/use-builtin-tools";
import { ToolRow } from "./builtin-tool-row";
interface CategoryGroupProps {
category: string;
tools: BuiltinToolData[];
onToggle: (tool: BuiltinToolData) => void;
onSettings: (tool: BuiltinToolData) => void;
tenantId: string | null;
onSetTenantConfig: (name: string, enabled: boolean) => Promise<void>;
onDeleteTenantConfig: (name: string) => Promise<void>;
}
export function CategoryGroup({
category,
tools,
onToggle,
onSettings,
tenantId,
onSetTenantConfig,
onDeleteTenantConfig,
}: CategoryGroupProps) {
const { t } = useTranslation("tools");
return (
<div className="rounded-lg border">
<div className="flex items-center gap-2 border-b bg-muted/40 px-4 py-2">
<span className="text-sm font-medium">{t(`builtin.categories.${category}`, category)}</span>
<Badge variant="secondary" className="h-5 px-1.5 text-[11px]">
{tools.length}
</Badge>
</div>
<div className="divide-y">
{tools.map((tool) => (
<ToolRow
key={tool.name}
tool={tool}
onToggle={onToggle}
onSettings={onSettings}
tenantId={tenantId}
onSetTenantConfig={onSetTenantConfig}
onDeleteTenantConfig={onDeleteTenantConfig}
/>
))}
</div>
</div>
);
}
@@ -0,0 +1,213 @@
import { useTranslation } from "react-i18next";
import { RefreshCw, Settings, Info } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Switch } from "@/components/ui/switch";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import type { BuiltinToolData } from "./hooks/use-builtin-tools";
/* eslint-disable @typescript-eslint/no-explicit-any */
export function hasEditableSettings(tool: BuiltinToolData): boolean {
return tool.settings != null && Object.keys(tool.settings).length > 0;
}
export function getConfigHint(tool: BuiltinToolData): string | undefined {
return (tool.metadata as any)?.config_hint as string | undefined;
}
export function isDeprecated(tool: BuiltinToolData): boolean {
return (tool.metadata as any)?.deprecated === true;
}
interface TenantOverrideControlProps {
tool: BuiltinToolData;
hasOverride: boolean;
onSetTenantConfig: (name: string, enabled: boolean) => Promise<void>;
onDeleteTenantConfig: (name: string) => Promise<void>;
}
export function TenantOverrideControl({
tool,
hasOverride,
onSetTenantConfig,
onDeleteTenantConfig,
}: TenantOverrideControlProps) {
const { t } = useTranslation("tools");
const tenantEnabled = tool.tenant_enabled;
const overrideLabel = hasOverride
? tenantEnabled
? t("builtin.tenantEnabled")
: t("builtin.tenantDisabled")
: t("builtin.tenantDefault");
const badgeVariant = hasOverride
? tenantEnabled
? "default"
: "secondary"
: "outline";
return (
<div className="flex items-center gap-1 border-r pr-2 mr-0.5">
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<Badge
variant={badgeVariant}
className="h-5 cursor-default px-1.5 text-[10px] leading-none"
>
{overrideLabel}
</Badge>
</TooltipTrigger>
<TooltipContent side="top">
<p className="text-xs">{t("builtin.tenantOverride")}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<Switch
checked={hasOverride ? (tenantEnabled ?? false) : tool.enabled}
onCheckedChange={(checked) => onSetTenantConfig(tool.name, checked)}
className="scale-75 origin-right"
aria-label={t("builtin.tenantOverride")}
/>
{hasOverride && (
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={() => onDeleteTenantConfig(tool.name)}
className="h-5 w-5 p-0 text-muted-foreground hover:text-foreground"
aria-label={t("builtin.resetToDefault")}
>
<RefreshCw className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
<p className="text-xs">{t("builtin.resetToDefault")}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
);
}
interface ToolRowProps {
tool: BuiltinToolData;
onToggle: (tool: BuiltinToolData) => void;
onSettings: (tool: BuiltinToolData) => void;
tenantId: string | null;
onSetTenantConfig: (name: string, enabled: boolean) => Promise<void>;
onDeleteTenantConfig: (name: string) => Promise<void>;
}
export function ToolRow({
tool,
onToggle,
onSettings,
tenantId,
onSetTenantConfig,
onDeleteTenantConfig,
}: ToolRowProps) {
const { t } = useTranslation("tools");
const configHint = getConfigHint(tool);
const editable = hasEditableSettings(tool);
const deprecated = isDeprecated(tool);
const hasTenantScope = !!tenantId && tenantId !== "0193a5b0-7000-7000-8000-000000000001";
const hasOverride = tool.tenant_enabled !== null && tool.tenant_enabled !== undefined;
return (
<div className={`flex items-center gap-4 px-4 py-2 hover:bg-muted/30 transition-colors${deprecated ? " opacity-60" : ""}`}>
<div className="min-w-0 flex-1">
<div className="flex items-baseline gap-1.5">
<span className="text-sm font-medium leading-tight">{tool.display_name}</span>
<code className="text-[11px] text-muted-foreground">{tool.name}</code>
{deprecated && (
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<Badge variant="destructive" className="ml-1 h-4 px-1 text-[10px] leading-none cursor-default">
{t("builtin.deprecated")}
</Badge>
</TooltipTrigger>
<TooltipContent side="top">
<p className="text-xs">{t("builtin.deprecatedTooltip")}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{!deprecated && tool.requires && tool.requires.length > 0 && (
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<Badge variant="outline" className="ml-1 h-4 px-1 text-[10px] leading-none cursor-default">
{t("builtin.requires")}
</Badge>
</TooltipTrigger>
<TooltipContent side="top">
<p className="text-xs">{t("builtin.requiresTooltip", { list: tool.requires.join(", ") })}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
{tool.description && (
<p className="text-xs text-muted-foreground leading-snug truncate mt-0.5">
{t(`builtin.descriptions.${tool.name}`, tool.description)}
</p>
)}
</div>
<div className="flex items-center gap-1.5 shrink-0">
{editable && !deprecated && (
<Button
variant="ghost"
size="sm"
onClick={() => onSettings(tool)}
className="h-7 gap-1 px-2 text-xs"
>
<Settings className="h-3 w-3" />
{t("builtin.settings")}
</Button>
)}
{!editable && !deprecated && configHint && (
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<span className="flex items-center gap-1 text-[11px] text-muted-foreground cursor-default">
<Info className="h-3 w-3" />
{configHint}
</span>
</TooltipTrigger>
<TooltipContent side="top">
<p className="text-xs">{t("builtin.configuredVia")}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{hasTenantScope && !deprecated ? (
<TenantOverrideControl
tool={tool}
hasOverride={hasOverride}
onSetTenantConfig={onSetTenantConfig}
onDeleteTenantConfig={onDeleteTenantConfig}
/>
) : (
<Switch
checked={tool.enabled}
onCheckedChange={() => onToggle(tool)}
disabled={deprecated}
/>
)}
</div>
</div>
);
}
@@ -1,15 +1,7 @@
import { useState, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Package, RefreshCw, Settings, Info, AlertTriangle } from "lucide-react";
import { Package, RefreshCw, Settings, AlertTriangle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Switch } from "@/components/ui/switch";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { PageHeader } from "@/components/shared/page-header";
import { EmptyState } from "@/components/shared/empty-state";
import { SearchInput } from "@/components/shared/search-input";
@@ -20,26 +12,13 @@ import { MEDIA_TOOLS } from "./media-provider-params-schema";
import { useMinLoading } from "@/hooks/use-min-loading";
import { useDeferredLoading } from "@/hooks/use-deferred-loading";
import { useTenants } from "@/hooks/use-tenants";
/* eslint-disable @typescript-eslint/no-explicit-any */
import { CategoryGroup } from "./builtin-tool-category-group";
const CATEGORY_ORDER = [
"filesystem", "runtime", "web", "memory", "media", "browser",
"sessions", "messaging", "scheduling", "subagents", "skills", "delegation", "teams",
];
function hasEditableSettings(tool: BuiltinToolData): boolean {
return tool.settings != null && Object.keys(tool.settings).length > 0;
}
function getConfigHint(tool: BuiltinToolData): string | undefined {
return (tool.metadata as any)?.config_hint as string | undefined;
}
function isDeprecated(tool: BuiltinToolData): boolean {
return (tool.metadata as any)?.deprecated === true;
}
/** Media tool that is enabled but has no provider chain configured */
function needsProviderConfig(tool: BuiltinToolData): boolean {
if (!MEDIA_TOOLS.has(tool.name) || !tool.enabled) return false;
@@ -187,228 +166,3 @@ export function BuiltinToolsPage() {
);
}
function CategoryGroup({
category,
tools,
onToggle,
onSettings,
tenantId,
onSetTenantConfig,
onDeleteTenantConfig,
}: {
category: string;
tools: BuiltinToolData[];
onToggle: (tool: BuiltinToolData) => void;
onSettings: (tool: BuiltinToolData) => void;
tenantId: string | null;
onSetTenantConfig: (name: string, enabled: boolean) => Promise<void>;
onDeleteTenantConfig: (name: string) => Promise<void>;
}) {
const { t } = useTranslation("tools");
return (
<div className="rounded-lg border">
<div className="flex items-center gap-2 border-b bg-muted/40 px-4 py-2">
<span className="text-sm font-medium">{t(`builtin.categories.${category}`, category)}</span>
<Badge variant="secondary" className="h-5 px-1.5 text-[11px]">
{tools.length}
</Badge>
</div>
<div className="divide-y">
{tools.map((tool) => (
<ToolRow
key={tool.name}
tool={tool}
onToggle={onToggle}
onSettings={onSettings}
tenantId={tenantId}
onSetTenantConfig={onSetTenantConfig}
onDeleteTenantConfig={onDeleteTenantConfig}
/>
))}
</div>
</div>
);
}
function ToolRow({
tool,
onToggle,
onSettings,
tenantId,
onSetTenantConfig,
onDeleteTenantConfig,
}: {
tool: BuiltinToolData;
onToggle: (tool: BuiltinToolData) => void;
onSettings: (tool: BuiltinToolData) => void;
tenantId: string | null;
onSetTenantConfig: (name: string, enabled: boolean) => Promise<void>;
onDeleteTenantConfig: (name: string) => Promise<void>;
}) {
const { t } = useTranslation("tools");
const configHint = getConfigHint(tool);
const editable = hasEditableSettings(tool);
const deprecated = isDeprecated(tool);
const hasTenantScope = !!tenantId && tenantId !== "0193a5b0-7000-7000-8000-000000000001";
const hasOverride = tool.tenant_enabled !== null && tool.tenant_enabled !== undefined;
return (
<div className={`flex items-center gap-4 px-4 py-2 hover:bg-muted/30 transition-colors${deprecated ? " opacity-60" : ""}`}>
<div className="min-w-0 flex-1">
<div className="flex items-baseline gap-1.5">
<span className="text-sm font-medium leading-tight">{tool.display_name}</span>
<code className="text-[11px] text-muted-foreground">{tool.name}</code>
{deprecated && (
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<Badge variant="destructive" className="ml-1 h-4 px-1 text-[10px] leading-none cursor-default">
{t("builtin.deprecated")}
</Badge>
</TooltipTrigger>
<TooltipContent side="top">
<p className="text-xs">{t("builtin.deprecatedTooltip")}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{!deprecated && tool.requires && tool.requires.length > 0 && (
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<Badge variant="outline" className="ml-1 h-4 px-1 text-[10px] leading-none cursor-default">
{t("builtin.requires")}
</Badge>
</TooltipTrigger>
<TooltipContent side="top">
<p className="text-xs">{t("builtin.requiresTooltip", { list: tool.requires.join(", ") })}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
{tool.description && (
<p className="text-xs text-muted-foreground leading-snug truncate mt-0.5">
{t(`builtin.descriptions.${tool.name}`, tool.description)}
</p>
)}
</div>
<div className="flex items-center gap-1.5 shrink-0">
{editable && !deprecated && (
<Button
variant="ghost"
size="sm"
onClick={() => onSettings(tool)}
className="h-7 gap-1 px-2 text-xs"
>
<Settings className="h-3 w-3" />
{t("builtin.settings")}
</Button>
)}
{!editable && !deprecated && configHint && (
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<span className="flex items-center gap-1 text-[11px] text-muted-foreground cursor-default">
<Info className="h-3 w-3" />
{configHint}
</span>
</TooltipTrigger>
<TooltipContent side="top">
<p className="text-xs">{t("builtin.configuredVia")}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{hasTenantScope && !deprecated ? (
<TenantOverrideControl
tool={tool}
hasOverride={hasOverride}
onSetTenantConfig={onSetTenantConfig}
onDeleteTenantConfig={onDeleteTenantConfig}
/>
) : (
<Switch
checked={tool.enabled}
onCheckedChange={() => onToggle(tool)}
disabled={deprecated}
/>
)}
</div>
</div>
);
}
function TenantOverrideControl({
tool,
hasOverride,
onSetTenantConfig,
onDeleteTenantConfig,
}: {
tool: BuiltinToolData;
hasOverride: boolean;
onSetTenantConfig: (name: string, enabled: boolean) => Promise<void>;
onDeleteTenantConfig: (name: string) => Promise<void>;
}) {
const { t } = useTranslation("tools");
const tenantEnabled = tool.tenant_enabled;
const overrideLabel = hasOverride
? tenantEnabled
? t("builtin.tenantEnabled")
: t("builtin.tenantDisabled")
: t("builtin.tenantDefault");
const badgeVariant = hasOverride
? tenantEnabled
? "default"
: "secondary"
: "outline";
return (
<div className="flex items-center gap-1 border-r pr-2 mr-0.5">
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<Badge
variant={badgeVariant}
className="h-5 cursor-default px-1.5 text-[10px] leading-none"
>
{overrideLabel}
</Badge>
</TooltipTrigger>
<TooltipContent side="top">
<p className="text-xs">{t("builtin.tenantOverride")}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<Switch
checked={hasOverride ? (tenantEnabled ?? false) : tool.enabled}
onCheckedChange={(checked) => onSetTenantConfig(tool.name, checked)}
className="scale-75 origin-right"
aria-label={t("builtin.tenantOverride")}
/>
{hasOverride && (
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={() => onDeleteTenantConfig(tool.name)}
className="h-5 w-5 p-0 text-muted-foreground hover:text-foreground"
aria-label={t("builtin.resetToDefault")}
>
<RefreshCw className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
<p className="text-xs">{t("builtin.resetToDefault")}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
);
}
@@ -0,0 +1,75 @@
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";
import type { ParamField } from "./media-provider-params-schema";
/** Renders a single provider param field based on its type declaration. */
export function ParamFieldControl({
field,
value,
onChange,
}: {
field: ParamField;
value: unknown;
onChange: (v: unknown) => void;
}) {
return (
<div className="space-y-1">
<Label className="text-xs">{field.label}</Label>
{field.type === "select" && field.options && (
<Select value={String(value ?? "")} onValueChange={onChange}>
<SelectTrigger className="h-8 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{field.options.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
{field.type === "toggle" && (
<div className="flex items-center h-8">
<Switch
size="sm"
checked={Boolean(value)}
onCheckedChange={onChange}
/>
</div>
)}
{field.type === "number" && (
<Input
type="number"
min={field.min}
max={field.max}
step={field.step}
value={Number(value ?? 0)}
onChange={(e) => onChange(Number(e.target.value))}
className="h-8 text-sm"
/>
)}
{field.type === "text" && (
<div className="space-y-1">
<Input
value={String(value ?? "")}
onChange={(e) => onChange(e.target.value)}
placeholder={field.description}
className="h-8 text-sm"
/>
{field.description && (
<p className="text-xs text-muted-foreground">{field.description}</p>
)}
</div>
)}
</div>
);
}
@@ -12,40 +12,17 @@ import {
import {
SortableContext,
verticalListSortingStrategy,
useSortable,
arrayMove,
sortableKeyboardCoordinates,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { GripVertical, Trash2, ChevronDown, ChevronUp, Plus, Loader2 } from "lucide-react";
import { Plus, Loader2 } from "lucide-react";
import { uniqueId } from "@/lib/utils";
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";
import { Combobox } from "@/components/ui/combobox";
import { DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
import { useProviders } from "@/pages/providers/hooks/use-providers";
import { useProviderModels } from "@/pages/providers/hooks/use-provider-models";
import { MEDIA_PARAMS_SCHEMA, type ParamField } from "./media-provider-params-schema";
interface ProviderEntry {
id: string;
provider_id: string;
provider: string;
model: string;
enabled: boolean;
timeout: number;
max_retries: number;
params: Record<string, unknown>;
}
import { SortableProviderCard } from "./media-sortable-provider-card";
import type { ProviderEntry } from "./media-provider-chain-helpers";
import { formatToolTitle, parseInitialEntries } from "./media-provider-chain-helpers";
interface MediaProviderChainFormProps {
toolName: string;
@@ -54,322 +31,7 @@ interface MediaProviderChainFormProps {
onCancel: () => void;
}
function formatToolTitle(name: string): string {
return name
.replace(/_/g, " ")
.replace(/\b\w/g, (c) => c.toUpperCase());
}
function buildDefaultParams(toolName: string, providerType: string): Record<string, unknown> {
const schema = MEDIA_PARAMS_SCHEMA[toolName]?.[providerType] ?? [];
const defaults: Record<string, unknown> = {};
for (const field of schema) {
if (field.default !== undefined) {
defaults[field.key] = field.default;
}
}
return defaults;
}
function parseInitialEntries(
settings: Record<string, unknown>,
providers: ReturnType<typeof useProviders>["providers"],
): ProviderEntry[] {
// New format: { providers: [...] }
// Only include entries whose provider exists in the current tenant.
if (Array.isArray(settings.providers)) {
return (settings.providers as Record<string, unknown>[])
.map((p) => {
const name = String(p.provider ?? "");
const pid = String(p.provider_id ?? "");
// Resolve provider_id: use stored id if valid, otherwise look up by name
const resolved = (pid && providers.some((pr) => pr.id === pid))
? pid
: providers.find((pr) => pr.name === name)?.id ?? "";
// Skip entries whose provider doesn't exist in this tenant
if (!resolved) return null;
return {
id: uniqueId(),
provider_id: resolved,
provider: name,
model: String(p.model ?? ""),
enabled: Boolean(p.enabled ?? true),
timeout: Number(p.timeout ?? 120),
max_retries: Number(p.max_retries ?? 2),
params: (p.params as Record<string, unknown>) ?? {},
};
})
.filter((e): e is ProviderEntry => e !== null);
}
// Legacy format: { provider, model }
// Only show if the provider exists in the current tenant's provider list.
if (settings.provider || settings.model) {
const providerName = String(settings.provider ?? "");
const providerData = providers.find((p) => p.name === providerName);
if (providerData) {
return [
{
id: uniqueId(),
provider_id: providerData.id,
provider: providerName,
model: String(settings.model ?? ""),
enabled: true,
timeout: 120,
max_retries: 2,
params: {},
},
];
}
// Provider not available in this tenant — show empty chain
}
return [];
}
// Sub-component for a single sortable provider card
interface SortableCardProps {
entry: ProviderEntry;
index: number;
toolName: string;
enabledProviders: ReturnType<typeof useProviders>["providers"];
onUpdate: (id: string, patch: Partial<ProviderEntry>) => void;
onRemove: (id: string) => void;
portalRef: React.RefObject<HTMLDivElement | null>;
}
function SortableProviderCard({ entry, index, toolName, enabledProviders, onUpdate, onRemove, portalRef }: SortableCardProps) {
const { t } = useTranslation("tools");
const [expanded, setExpanded] = useState(false);
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: entry.id,
});
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
};
const selectedProvider = enabledProviders.find((p) => p.id === entry.provider_id);
const { models, loading: modelsLoading } = useProviderModels(
entry.provider_id || undefined,
);
const paramSchema: ParamField[] =
MEDIA_PARAMS_SCHEMA[toolName]?.[selectedProvider?.provider_type ?? ""] ?? [];
const handleProviderChange = (providerName: string) => {
const pData = enabledProviders.find((p) => p.name === providerName);
const newParams = pData
? buildDefaultParams(toolName, pData.provider_type)
: {};
onUpdate(entry.id, {
provider_id: pData?.id ?? "",
provider: providerName,
model: "",
params: newParams,
});
};
const handleParamChange = (key: string, value: unknown) => {
onUpdate(entry.id, { params: { ...entry.params, [key]: value } });
};
return (
<div
ref={setNodeRef}
style={style}
className={`border rounded-lg bg-card ${!entry.enabled ? "opacity-60" : ""}`}
>
{/* Row 1: drag handle, index, toggle, delete */}
<div className="flex items-center gap-2 px-3 pt-3 pb-1">
<button
type="button"
className="cursor-grab text-muted-foreground hover:text-foreground shrink-0"
{...attributes}
{...listeners}
>
<GripVertical className="size-4" />
</button>
<span className="text-xs text-muted-foreground font-mono shrink-0">
#{index + 1}
</span>
<Switch
size="sm"
checked={entry.enabled}
onCheckedChange={(v) => onUpdate(entry.id, { enabled: v })}
/>
<span className="text-sm font-medium truncate">
{selectedProvider?.display_name || entry.provider || t("builtin.mediaChain.newProvider")}
</span>
<Button
type="button"
variant="ghost"
size="sm"
className="ml-auto h-7 w-7 p-0 shrink-0 text-muted-foreground hover:text-destructive"
onClick={() => onRemove(entry.id)}
>
<Trash2 className="size-3.5" />
</Button>
</div>
{/* Row 2: provider + model selects */}
<div className="grid grid-cols-1 gap-2 px-3 py-1.5 sm:grid-cols-2">
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">{t("builtin.mediaChain.provider")}</Label>
<Select value={entry.provider} onValueChange={handleProviderChange}>
<SelectTrigger className="h-8 text-sm">
<SelectValue placeholder={t("builtin.mediaChain.selectProvider")} />
</SelectTrigger>
<SelectContent>
{enabledProviders.map((p) => (
<SelectItem key={p.id} value={p.name}>
{p.display_name || p.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">{t("builtin.mediaChain.model")}</Label>
<Combobox
value={entry.model}
onChange={(v) => onUpdate(entry.id, { model: v })}
options={models.map((m) => ({ value: m.id, label: m.name ?? m.id }))}
placeholder={modelsLoading ? t("builtin.mediaChain.loadingModels") : t("builtin.mediaChain.selectModel")}
className="h-8 text-sm"
portalContainer={portalRef}
/>
</div>
</div>
{/* Row 3: timeout, retries, expand button */}
<div className="flex items-center gap-3 px-3 pb-3 pt-1">
<div className="flex items-center gap-1.5">
<Label className="text-xs text-muted-foreground whitespace-nowrap">{t("builtin.mediaChain.timeout")}</Label>
<div className="relative">
<Input
type="number"
min={1}
max={600}
value={entry.timeout}
onChange={(e) => onUpdate(entry.id, { timeout: Number(e.target.value) })}
className="h-7 w-20 text-sm pr-5"
/>
<span className="absolute right-2 top-1/2 -translate-y-1/2 text-xs text-muted-foreground pointer-events-none">s</span>
</div>
</div>
<div className="flex items-center gap-1.5">
<Label className="text-xs text-muted-foreground whitespace-nowrap">{t("builtin.mediaChain.retries")}</Label>
<Input
type="number"
min={0}
max={10}
value={entry.max_retries}
onChange={(e) => onUpdate(entry.id, { max_retries: Number(e.target.value) })}
className="h-7 w-16 text-sm"
/>
</div>
{paramSchema.length > 0 && (
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="ml-auto flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
>
{t("builtin.mediaChain.settings")}
{expanded ? <ChevronUp className="size-3" /> : <ChevronDown className="size-3" />}
</button>
)}
</div>
{/* Collapsible params */}
{expanded && paramSchema.length > 0 && (
<div className="border-t px-3 py-3 grid grid-cols-1 gap-3 sm:grid-cols-2">
{paramSchema.map((field) => (
<ParamFieldControl
key={field.key}
field={field}
value={entry.params[field.key] ?? field.default}
onChange={(v) => handleParamChange(field.key, v)}
/>
))}
</div>
)}
</div>
);
}
// Renders a single param field based on its type
function ParamFieldControl({
field,
value,
onChange,
}: {
field: ParamField;
value: unknown;
onChange: (v: unknown) => void;
}) {
return (
<div className="space-y-1">
<Label className="text-xs">{field.label}</Label>
{field.type === "select" && field.options && (
<Select value={String(value ?? "")} onValueChange={onChange}>
<SelectTrigger className="h-8 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{field.options.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
{field.type === "toggle" && (
<div className="flex items-center h-8">
<Switch
size="sm"
checked={Boolean(value)}
onCheckedChange={onChange}
/>
</div>
)}
{field.type === "number" && (
<Input
type="number"
min={field.min}
max={field.max}
step={field.step}
value={Number(value ?? 0)}
onChange={(e) => onChange(Number(e.target.value))}
className="h-8 text-sm"
/>
)}
{field.type === "text" && (
<div className="space-y-1">
<Input
value={String(value ?? "")}
onChange={(e) => onChange(e.target.value)}
placeholder={field.description}
className="h-8 text-sm"
/>
{field.description && (
<p className="text-xs text-muted-foreground">{field.description}</p>
)}
</div>
)}
</div>
);
}
// Main form component
/** Dialog form for configuring an ordered provider chain for a media tool. */
export function MediaProviderChainForm({
toolName,
initialSettings,
@@ -0,0 +1,83 @@
import { uniqueId } from "@/lib/utils";
import { MEDIA_PARAMS_SCHEMA } from "./media-provider-params-schema";
export interface ProviderEntry {
id: string;
provider_id: string;
provider: string;
model: string;
enabled: boolean;
timeout: number;
max_retries: number;
params: Record<string, unknown>;
}
/** Convert tool_name to display title (e.g. "text_to_speech" → "Text To Speech"). */
export function formatToolTitle(name: string): string {
return name
.replace(/_/g, " ")
.replace(/\b\w/g, (c) => c.toUpperCase());
}
/** Build default param values for a given tool + provider type from schema. */
export function buildDefaultParams(toolName: string, providerType: string): Record<string, unknown> {
const schema = MEDIA_PARAMS_SCHEMA[toolName]?.[providerType] ?? [];
const defaults: Record<string, unknown> = {};
for (const field of schema) {
if (field.default !== undefined) {
defaults[field.key] = field.default;
}
}
return defaults;
}
/** Parse stored settings into ProviderEntry list, filtering out unavailable providers. */
export function parseInitialEntries(
settings: Record<string, unknown>,
providers: Array<{ id: string; name: string }>,
): ProviderEntry[] {
// New format: { providers: [...] }
if (Array.isArray(settings.providers)) {
return (settings.providers as Record<string, unknown>[])
.map((p) => {
const name = String(p.provider ?? "");
const pid = String(p.provider_id ?? "");
const resolved = (pid && providers.some((pr) => pr.id === pid))
? pid
: providers.find((pr) => pr.name === name)?.id ?? "";
if (!resolved) return null;
return {
id: uniqueId(),
provider_id: resolved,
provider: name,
model: String(p.model ?? ""),
enabled: Boolean(p.enabled ?? true),
timeout: Number(p.timeout ?? 120),
max_retries: Number(p.max_retries ?? 2),
params: (p.params as Record<string, unknown>) ?? {},
};
})
.filter((e): e is ProviderEntry => e !== null);
}
// Legacy format: { provider, model }
if (settings.provider || settings.model) {
const providerName = String(settings.provider ?? "");
const providerData = providers.find((p) => p.name === providerName);
if (providerData) {
return [
{
id: uniqueId(),
provider_id: providerData.id,
provider: providerName,
model: String(settings.model ?? ""),
enabled: true,
timeout: 120,
max_retries: 2,
params: {},
},
];
}
}
return [];
}
@@ -0,0 +1,207 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { GripVertical, Trash2, ChevronDown, ChevronUp } 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";
import { Combobox } from "@/components/ui/combobox";
import { useProviders } from "@/pages/providers/hooks/use-providers";
import { useProviderModels } from "@/pages/providers/hooks/use-provider-models";
import { MEDIA_PARAMS_SCHEMA } from "./media-provider-params-schema";
import { ParamFieldControl } from "./media-param-field-control";
import { buildDefaultParams } from "./media-provider-chain-helpers";
import type { ProviderEntry } from "./media-provider-chain-helpers";
interface SortableCardProps {
entry: ProviderEntry;
index: number;
toolName: string;
enabledProviders: ReturnType<typeof useProviders>["providers"];
onUpdate: (id: string, patch: Partial<ProviderEntry>) => void;
onRemove: (id: string) => void;
portalRef: React.RefObject<HTMLDivElement | null>;
}
/** Single draggable/sortable provider card in the media chain form. */
export function SortableProviderCard({
entry,
index,
toolName,
enabledProviders,
onUpdate,
onRemove,
portalRef,
}: SortableCardProps) {
const { t } = useTranslation("tools");
const [expanded, setExpanded] = useState(false);
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: entry.id,
});
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
};
const selectedProvider = enabledProviders.find((p) => p.id === entry.provider_id);
const { models, loading: modelsLoading } = useProviderModels(
entry.provider_id || undefined,
);
const paramSchema = MEDIA_PARAMS_SCHEMA[toolName]?.[selectedProvider?.provider_type ?? ""] ?? [];
const handleProviderChange = (providerName: string) => {
const pData = enabledProviders.find((p) => p.name === providerName);
const newParams = pData ? buildDefaultParams(toolName, pData.provider_type) : {};
onUpdate(entry.id, {
provider_id: pData?.id ?? "",
provider: providerName,
model: "",
params: newParams,
});
};
const handleParamChange = (key: string, value: unknown) => {
onUpdate(entry.id, { params: { ...entry.params, [key]: value } });
};
return (
<div
ref={setNodeRef}
style={style}
className={`border rounded-lg bg-card ${!entry.enabled ? "opacity-60" : ""}`}
>
{/* Row 1: drag handle, index, toggle, delete */}
<div className="flex items-center gap-2 px-3 pt-3 pb-1">
<button
type="button"
className="cursor-grab text-muted-foreground hover:text-foreground shrink-0"
{...attributes}
{...listeners}
>
<GripVertical className="size-4" />
</button>
<span className="text-xs text-muted-foreground font-mono shrink-0">
#{index + 1}
</span>
<Switch
size="sm"
checked={entry.enabled}
onCheckedChange={(v) => onUpdate(entry.id, { enabled: v })}
/>
<span className="text-sm font-medium truncate">
{selectedProvider?.display_name || entry.provider || t("builtin.mediaChain.newProvider")}
</span>
<Button
type="button"
variant="ghost"
size="sm"
className="ml-auto h-7 w-7 p-0 shrink-0 text-muted-foreground hover:text-destructive"
onClick={() => onRemove(entry.id)}
>
<Trash2 className="size-3.5" />
</Button>
</div>
{/* Row 2: provider + model selects */}
<div className="grid grid-cols-1 gap-2 px-3 py-1.5 sm:grid-cols-2">
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">{t("builtin.mediaChain.provider")}</Label>
<Select value={entry.provider} onValueChange={handleProviderChange}>
<SelectTrigger className="h-8 text-sm">
<SelectValue placeholder={t("builtin.mediaChain.selectProvider")} />
</SelectTrigger>
<SelectContent>
{enabledProviders.map((p) => (
<SelectItem key={p.id} value={p.name}>
{p.display_name || p.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">{t("builtin.mediaChain.model")}</Label>
<Combobox
value={entry.model}
onChange={(v) => onUpdate(entry.id, { model: v })}
options={models.map((m) => ({ value: m.id, label: m.name ?? m.id }))}
placeholder={modelsLoading ? t("builtin.mediaChain.loadingModels") : t("builtin.mediaChain.selectModel")}
className="h-8 text-sm"
portalContainer={portalRef}
/>
</div>
</div>
{/* Row 3: timeout, retries, expand button */}
<div className="flex items-center gap-3 px-3 pb-3 pt-1">
<div className="flex items-center gap-1.5">
<Label className="text-xs text-muted-foreground whitespace-nowrap">{t("builtin.mediaChain.timeout")}</Label>
<div className="relative">
<Input
type="number"
min={1}
max={600}
value={entry.timeout}
onChange={(e) => onUpdate(entry.id, { timeout: Number(e.target.value) })}
className="h-7 w-20 text-sm pr-5"
/>
<span className="absolute right-2 top-1/2 -translate-y-1/2 text-xs text-muted-foreground pointer-events-none">s</span>
</div>
</div>
<div className="flex items-center gap-1.5">
<Label className="text-xs text-muted-foreground whitespace-nowrap">{t("builtin.mediaChain.retries")}</Label>
<Input
type="number"
min={0}
max={10}
value={entry.max_retries}
onChange={(e) => onUpdate(entry.id, { max_retries: Number(e.target.value) })}
className="h-7 w-16 text-sm"
/>
</div>
{paramSchema.length > 0 && (
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="ml-auto flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
>
{t("builtin.mediaChain.settings")}
{expanded ? <ChevronUp className="size-3" /> : <ChevronDown className="size-3" />}
</button>
)}
</div>
{/* Collapsible params */}
{expanded && paramSchema.length > 0 && (
<div className="border-t px-3 py-3 grid grid-cols-1 gap-3 sm:grid-cols-2">
{paramSchema.map((field) => (
<ParamFieldControl
key={field.key}
field={field}
value={entry.params[field.key] ?? field.default}
onChange={(v) => handleParamChange(field.key, v)}
/>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,109 @@
/**
* InlineAddForm — per-group manager add form used inside ChannelManagersTab.
* Two render modes:
* - with groupId prop: compact inline form (no group ID field)
* - showGroupField: expanded form with group ID + user ID fields
*/
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Plus, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { UserPickerCombobox } from "@/components/shared/user-picker-combobox";
export interface InlineAddFormProps {
groupId?: string;
showGroupField?: boolean;
onAdd: (groupId: string, userId: string, displayName: string, username: string) => Promise<void>;
}
export function InlineAddForm({ groupId, showGroupField, onAdd }: InlineAddFormProps) {
const { t } = useTranslation("channels");
const [formGroupId, setFormGroupId] = useState("");
const [userId, setUserId] = useState("");
const [adding, setAdding] = useState(false);
const [error, setError] = useState("");
const handleSubmit = async () => {
const gid = groupId || formGroupId.trim();
const uid = userId.trim();
if (!gid || !uid) {
setError(t("detail.managers.addForm.errors.groupUserRequired"));
return;
}
setAdding(true);
setError("");
try {
await onAdd(gid, uid, "", "");
setUserId("");
if (!groupId) setFormGroupId("");
} catch (err) {
setError(err instanceof Error ? err.message : t("detail.managers.addForm.errors.failedAdd"));
} finally {
setAdding(false);
}
};
if (showGroupField) {
return (
<fieldset className="rounded-md border p-4 space-y-3">
<legend className="px-1 text-sm font-medium">{t("detail.managers.addForm.title")}</legend>
<p className="text-xs text-muted-foreground">{t("detail.managers.addForm.hint")}</p>
<div className="flex flex-wrap items-end gap-2">
<div className="grid gap-1.5 flex-1 min-w-[180px]">
<Label className="text-xs">{t("detail.managers.addForm.groupId")}</Label>
<Input
value={formGroupId}
onChange={(e) => setFormGroupId(e.target.value)}
placeholder={t("detail.managers.addForm.groupIdPlaceholder")}
className="text-base md:text-sm"
/>
</div>
<div className="grid gap-1.5 flex-1 min-w-[180px]">
<Label className="text-xs">{t("detail.managers.addForm.userId")}</Label>
<UserPickerCombobox
value={userId}
onChange={setUserId}
placeholder={t("detail.managers.addForm.userIdPlaceholder")}
/>
</div>
<Button
onClick={handleSubmit}
disabled={adding || !formGroupId.trim() || !userId.trim()}
size="sm"
className="h-9 gap-1 shrink-0"
>
{adding ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Plus className="h-3.5 w-3.5" />}
{t("detail.managers.addForm.addManager")}
</Button>
</div>
{error && <p className="text-sm text-destructive mt-1">{error}</p>}
</fieldset>
);
}
return (
<div className="flex items-end gap-2">
<div className="grid gap-1 flex-1 min-w-[140px]">
<Label className="text-xs text-muted-foreground">{t("detail.managers.addForm.userId")}</Label>
<UserPickerCombobox
value={userId}
onChange={setUserId}
placeholder={t("detail.managers.addForm.userIdPlaceholder")}
className="h-8"
/>
</div>
<Button
size="sm"
className="h-8 gap-1 shrink-0"
onClick={handleSubmit}
disabled={adding || !userId.trim()}
>
{adding ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Plus className="h-3.5 w-3.5" />}
{t("detail.managers.addForm.add")}
</Button>
{error && <p className="text-xs text-destructive mt-1">{error}</p>}
</div>
);
}
@@ -1,12 +1,10 @@
import { useState, useEffect, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { Plus, Trash2, Loader2, RefreshCw, Users, ChevronDown, ChevronRight } from "lucide-react";
import { Trash2, Loader2, RefreshCw, Users, ChevronDown, ChevronRight } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { EmptyState } from "@/components/shared/empty-state";
import { UserPickerCombobox } from "@/components/shared/user-picker-combobox";
import type { GroupManagerGroupInfo, GroupManagerData } from "../hooks/use-channel-detail";
import { InlineAddForm } from "./channel-manager-inline-add-form";
interface ChannelManagersTabProps {
listManagerGroups: () => Promise<GroupManagerGroupInfo[]>;
@@ -21,106 +19,6 @@ function shortGroupId(id: string): string {
return m?.[1] ?? id;
}
// --- Inline add form (scoped state per instance) ---
interface InlineAddFormProps {
groupId?: string;
showGroupField?: boolean;
onAdd: (groupId: string, userId: string, displayName: string, username: string) => Promise<void>;
}
function InlineAddForm({ groupId, showGroupField, onAdd }: InlineAddFormProps) {
const { t } = useTranslation("channels");
const [formGroupId, setFormGroupId] = useState("");
const [userId, setUserId] = useState("");
const [adding, setAdding] = useState(false);
const [error, setError] = useState("");
const handleSubmit = async () => {
const gid = groupId || formGroupId.trim();
const uid = userId.trim();
if (!gid || !uid) {
setError(t("detail.managers.addForm.errors.groupUserRequired"));
return;
}
setAdding(true);
setError("");
try {
await onAdd(gid, uid, "", "");
setUserId("");
if (!groupId) setFormGroupId("");
} catch (err) {
setError(err instanceof Error ? err.message : t("detail.managers.addForm.errors.failedAdd"));
} finally {
setAdding(false);
}
};
if (showGroupField) {
return (
<fieldset className="rounded-md border p-4 space-y-3">
<legend className="px-1 text-sm font-medium">{t("detail.managers.addForm.title")}</legend>
<p className="text-xs text-muted-foreground">{t("detail.managers.addForm.hint")}</p>
<div className="flex flex-wrap items-end gap-2">
<div className="grid gap-1.5 flex-1 min-w-[180px]">
<Label className="text-xs">{t("detail.managers.addForm.groupId")}</Label>
<Input
value={formGroupId}
onChange={(e) => setFormGroupId(e.target.value)}
placeholder={t("detail.managers.addForm.groupIdPlaceholder")}
className="text-base md:text-sm"
/>
</div>
<div className="grid gap-1.5 flex-1 min-w-[180px]">
<Label className="text-xs">{t("detail.managers.addForm.userId")}</Label>
<UserPickerCombobox
value={userId}
onChange={setUserId}
placeholder={t("detail.managers.addForm.userIdPlaceholder")}
/>
</div>
<Button
onClick={handleSubmit}
disabled={adding || !formGroupId.trim() || !userId.trim()}
size="sm"
className="h-9 gap-1 shrink-0"
>
{adding ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Plus className="h-3.5 w-3.5" />}
{t("detail.managers.addForm.addManager")}
</Button>
</div>
{error && <p className="text-sm text-destructive mt-1">{error}</p>}
</fieldset>
);
}
return (
<div className="flex items-end gap-2">
<div className="grid gap-1 flex-1 min-w-[140px]">
<Label className="text-xs text-muted-foreground">{t("detail.managers.addForm.userId")}</Label>
<UserPickerCombobox
value={userId}
onChange={setUserId}
placeholder={t("detail.managers.addForm.userIdPlaceholder")}
className="h-8"
/>
</div>
<Button
size="sm"
className="h-8 gap-1 shrink-0"
onClick={handleSubmit}
disabled={adding || !userId.trim()}
>
{adding ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Plus className="h-3.5 w-3.5" />}
{t("detail.managers.addForm.add")}
</Button>
{error && <p className="text-xs text-destructive mt-1">{error}</p>}
</div>
);
}
// --- Main tab component ---
export function ChannelManagersTab({
listManagerGroups,
listManagers,
@@ -0,0 +1,70 @@
import type { Message } from "@/types/session";
import type { ChatMessage, ToolStreamEntry, MediaItem } from "@/types/chat";
import { toFileUrl } from "@/lib/file-helpers";
import { messageToTimestamp } from "@/lib/message-utils";
/**
* Transform raw Message[] from history RPC into ChatMessage[] for display.
* Reconstructs toolDetails from tool_calls + tool result messages,
* and converts media_refs to MediaItem[] for gallery.
*/
export function transformHistoryMessages(
allMsgs: Message[],
mediaItems?: MediaItem[],
): ChatMessage[] {
// Build a map of tool_call_id -> tool message for result lookup
const toolResultMap = new Map<string, Message>();
for (const m of allMsgs) {
if (m.role === "tool" && m.tool_call_id) {
toolResultMap.set(m.tool_call_id, m);
}
}
const msgs: ChatMessage[] = allMsgs.map((m: Message, i: number) => {
const chatMsg: ChatMessage = {
...m,
timestamp: messageToTimestamp(m, i, allMsgs.length),
};
// Convert persisted media_refs to mediaItems for gallery display
if (m.role === "assistant" && m.media_refs && m.media_refs.length > 0) {
chatMsg.mediaItems = m.media_refs.map((ref) => ({
path: toFileUrl(ref.path || ref.id),
mimeType: ref.mime_type,
fileName: (ref.path?.split("?")[0]?.split("/").pop()) ?? ref.id,
kind: (ref.kind as MediaItem["kind"]) || "document",
}));
}
// Reconstruct toolDetails for assistant messages with tool_calls
if (m.role === "assistant" && m.tool_calls && m.tool_calls.length > 0) {
chatMsg.toolDetails = m.tool_calls.map((tc) => {
const toolMsg = toolResultMap.get(tc.id);
return {
toolCallId: tc.id,
runId: "",
name: tc.name,
phase: (toolMsg ? (toolMsg.is_error ? "error" : "completed") : "calling") as ToolStreamEntry["phase"],
startedAt: 0,
updatedAt: 0,
arguments: tc.arguments,
result: toolMsg?.content,
};
});
}
return chatMsg;
});
// Attach media to the last assistant message if provided
if (mediaItems?.length && msgs.length > 0) {
for (let i = msgs.length - 1; i >= 0; i--) {
if (msgs[i]!.role === "assistant") {
msgs[i] = { ...msgs[i]!, mediaItems };
break;
}
}
}
return msgs;
}
+81 -442
View File
@@ -1,18 +1,16 @@
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
import { useState, useEffect, useCallback, useRef } from "react";
import { useWs } from "@/hooks/use-ws";
import { useWsEvent } from "@/hooks/use-ws-event";
import { Methods, Events } from "@/api/protocol";
import type { Message } from "@/types/session";
import type { ChatMessage, AgentEventPayload, ToolStreamEntry, RunActivity, ActiveTeamTask, MediaItem } from "@/types/chat";
import { toFileUrl, mediaKindFromMime } from "@/lib/file-helpers";
import { messageToTimestamp } from "@/lib/message-utils";
import { transformHistoryMessages } from "./chat-message-transformer";
import { useChatTeamTasks } from "./use-chat-team-tasks";
/**
* Manages chat message history and real-time streaming for a session.
* Listens to "agent" events for chunks, tool calls, and run lifecycle.
*
* The runId is captured from the first "run.started" event (not from the
* chat.send RPC response, which only arrives after the run completes).
*/
export function useChatMessages(sessionKey: string, agentId: string) {
const ws = useWs();
@@ -24,9 +22,8 @@ export function useChatMessages(sessionKey: string, agentId: string) {
const [loading, setLoading] = useState(false);
const [activity, setActivity] = useState<RunActivity | null>(null);
const [blockReplies, setBlockReplies] = useState<ChatMessage[]>([]);
const [teamTasks, setTeamTasks] = useState<ActiveTeamTask[]>([]);
// Use refs for values accessed inside the event handler to avoid stale closures.
// Refs for values accessed inside event handler to avoid stale closures
const runIdRef = useRef<string | null>(null);
const expectingRunRef = useRef(false);
const streamRef = useRef("");
@@ -41,221 +38,108 @@ export function useChatMessages(sessionKey: string, agentId: string) {
const rafPendingRef = useRef(false);
const rafHandleRef = useRef(0);
// Reset streaming/run state when session changes.
// Messages are NOT cleared here — loadHistory() will replace them atomically.
// loading is NOT set to true — avoids full-page flash while history loads.
// Add a local message optimistically
const addLocalMessage = useCallback((msg: ChatMessage) => {
setMessages((prev) => [...prev, msg]);
}, []);
// Team task handling (extracted hook)
const { teamTasks, setTeamTasks } = useChatTeamTasks(addLocalMessage);
// Reset streaming/run state when session changes
const prevKeyRef = useRef(sessionKey);
useEffect(() => {
if (sessionKey === prevKeyRef.current) return;
const wasEmpty = !prevKeyRef.current;
prevKeyRef.current = sessionKey;
if (wasEmpty) return; // new-chat send flow, don't reset
// "" → newKey: part of new-chat send flow. run.started may have already
// captured runIdRef before this effect fires. Don't reset run state —
// there's no prior session state to clean up from an empty session.
if (wasEmpty) return;
setStreamText(null); setThinkingText(null); setToolStream([]);
setIsRunning(false); setActivity(null); setBlockReplies([]); setTeamTasks([]);
runIdRef.current = null; expectingRunRef.current = false;
streamRef.current = ""; thinkingRef.current = "";
toolStreamRef.current = []; activityRef.current = null; blockRepliesRef.current = [];
cancelAnimationFrame(rafHandleRef.current); rafPendingRef.current = false;
if (!sessionKey) setMessages([]);
}, [sessionKey, setTeamTasks]);
// Session-to-session switch: full reset
setStreamText(null);
setThinkingText(null);
setToolStream([]);
setIsRunning(false);
setActivity(null);
setBlockReplies([]);
setTeamTasks([]);
runIdRef.current = null;
expectingRunRef.current = false;
streamRef.current = "";
thinkingRef.current = "";
toolStreamRef.current = [];
activityRef.current = null;
blockRepliesRef.current = [];
cancelAnimationFrame(rafHandleRef.current);
rafPendingRef.current = false;
// Clear messages when navigating away from a session (empty key).
// When switching to another session, loadHistory() will replace them.
if (!sessionKey) {
setMessages([]);
}
}, [sessionKey]);
// Load history (no loading spinner — the empty state placeholder is shown instead)
// Load history
const loadHistory = useCallback(async (mediaItems?: MediaItem[]) => {
if (!ws.isConnected || !sessionKey) {
setLoading(false);
return;
}
if (!ws.isConnected || !sessionKey) { setLoading(false); return; }
try {
const res = await ws.call<{ messages: Message[] }>(Methods.CHAT_HISTORY, {
agentId,
sessionKey,
});
const allMsgs = res.messages ?? [];
// Build a map of tool_call_id -> tool message for result lookup
const toolResultMap = new Map<string, Message>();
for (const m of allMsgs) {
if (m.role === "tool" && m.tool_call_id) {
toolResultMap.set(m.tool_call_id, m);
}
}
const msgs: ChatMessage[] = allMsgs.map((m: Message, i: number) => {
const chatMsg: ChatMessage = {
...m,
timestamp: messageToTimestamp(m, i, allMsgs.length),
};
// Convert persisted media_refs to mediaItems for gallery display
if (m.role === "assistant" && m.media_refs && m.media_refs.length > 0) {
chatMsg.mediaItems = m.media_refs.map((ref) => ({
path: toFileUrl(ref.path || ref.id),
mimeType: ref.mime_type,
fileName: (ref.path?.split("?")[0]?.split("/").pop()) ?? ref.id,
kind: (ref.kind as MediaItem["kind"]) || "document",
}));
}
// Reconstruct toolDetails for assistant messages with tool_calls
if (m.role === "assistant" && m.tool_calls && m.tool_calls.length > 0) {
chatMsg.toolDetails = m.tool_calls.map((tc) => {
const toolMsg = toolResultMap.get(tc.id);
return {
toolCallId: tc.id,
runId: "",
name: tc.name,
phase: (toolMsg ? (toolMsg.is_error ? "error" : "completed") : "calling") as ToolStreamEntry["phase"],
startedAt: 0,
updatedAt: 0,
arguments: tc.arguments,
result: toolMsg?.content,
};
});
}
return chatMsg;
});
// Attach media to the last assistant message if provided
if (mediaItems?.length && msgs.length > 0) {
for (let i = msgs.length - 1; i >= 0; i--) {
if (msgs[i]!.role === "assistant") {
msgs[i] = { ...msgs[i]!, mediaItems };
break;
}
}
}
setMessages(msgs);
} catch {
// will retry
} finally {
setLoading(false);
}
const res = await ws.call<{ messages: Message[] }>(Methods.CHAT_HISTORY, { agentId, sessionKey });
setMessages(transformHistoryMessages(res.messages ?? [], mediaItems));
} catch { /* will retry */ } finally { setLoading(false); }
}, [ws, agentId, sessionKey]);
// Load history, restore running state and active tasks when session changes.
// Cancelled flag prevents stale RPC responses from overwriting state on rapid switches.
// Load history + restore running state when session changes
useEffect(() => {
let cancelled = false;
if (sessionKey) {
loadHistory();
// Restore session running state (including runId for event filtering)
ws.call<{ isRunning?: boolean; runId?: string; activity?: RunActivity }>(Methods.CHAT_SESSION_STATUS, { sessionKey })
.then((res) => {
if (cancelled) return;
if (res.isRunning) {
setIsRunning(true);
if (res.runId) runIdRef.current = res.runId;
}
if (res.activity) {
setActivity(res.activity);
activityRef.current = res.activity;
}
})
.catch(() => {});
// Restore active team tasks (teams module may not be configured)
if (res.isRunning) { setIsRunning(true); if (res.runId) runIdRef.current = res.runId; }
if (res.activity) { setActivity(res.activity); activityRef.current = res.activity; }
}).catch(() => {});
ws.call<{ tasks?: ActiveTeamTask[] }>(Methods.TEAMS_TASK_ACTIVE_BY_SESSION, { sessionKey })
.then((res) => {
if (cancelled) return;
if (res.tasks && res.tasks.length > 0) setTeamTasks(res.tasks);
})
.catch(() => {});
.then((res) => { if (!cancelled && res.tasks?.length) setTeamTasks(res.tasks); }).catch(() => {});
}
return () => { cancelled = true; };
}, [sessionKey, loadHistory, ws]);
}, [sessionKey, loadHistory, ws, setTeamTasks]);
// Called before sending a message so the event handler knows to capture run.started
const expectRun = useCallback(() => {
expectingRunRef.current = true;
}, []);
// Called before sending so event handler captures run.started
const expectRun = useCallback(() => { expectingRunRef.current = true; }, []);
// Stable event handler using refs for mutable state
// Agent event handler
const handleAgentEvent = useCallback(
(payload: unknown) => {
const event = payload as AgentEventPayload;
if (!event) return;
if (event.channel && event.channel !== "ws" && !event.runKind) return;
if (event.sessionKey && event.sessionKey !== sessionKeyRef.current) return;
// Only process events from WS channel targeting the active session.
// Delegations/announces (runKind set) are allowed regardless of channel.
if (event.channel && event.channel !== "ws" && !event.runKind) {
return;
}
if (event.sessionKey && event.sessionKey !== sessionKeyRef.current) {
return;
}
// Capture run.started when we are expecting a run for this agent,
// OR when an announce run starts (leader summarising team results).
// Capture run.started
if (event.type === "run.started" && event.agentId === agentIdRef.current) {
if (expectingRunRef.current || event.runKind === "announce") {
runIdRef.current = event.runId;
expectingRunRef.current = false;
setIsRunning(true);
setStreamText(null);
setThinkingText(null);
setToolStream([]);
streamRef.current = "";
thinkingRef.current = "";
toolStreamRef.current = [];
runIdRef.current = event.runId; expectingRunRef.current = false;
setIsRunning(true); setStreamText(null); setThinkingText(null); setToolStream([]);
streamRef.current = ""; thinkingRef.current = ""; toolStreamRef.current = [];
}
return;
}
// All other events must match the active runId
if (!runIdRef.current || event.runId !== runIdRef.current) return;
switch (event.type) {
case "thinking": {
const content = event.payload?.content ?? "";
thinkingRef.current += content;
// Batch state updates: only one setState per animation frame
thinkingRef.current += event.payload?.content ?? "";
if (!rafPendingRef.current) {
rafPendingRef.current = true;
rafHandleRef.current = requestAnimationFrame(() => {
rafPendingRef.current = false;
setThinkingText(thinkingRef.current);
setStreamText(streamRef.current);
setThinkingText(thinkingRef.current); setStreamText(streamRef.current);
});
}
break;
}
case "chunk": {
const content = event.payload?.content ?? "";
streamRef.current += content;
// Batch state updates: only one setState per animation frame
streamRef.current += event.payload?.content ?? "";
if (!rafPendingRef.current) {
rafPendingRef.current = true;
rafHandleRef.current = requestAnimationFrame(() => {
rafPendingRef.current = false;
setStreamText(streamRef.current);
setThinkingText(thinkingRef.current);
setStreamText(streamRef.current); setThinkingText(thinkingRef.current);
});
}
break;
}
case "tool.call": {
const entry: ToolStreamEntry = {
toolCallId: event.payload?.id ?? "",
runId: event.runId,
name: event.payload?.name ?? "tool",
arguments: event.payload?.arguments,
phase: "calling",
startedAt: Date.now(),
updatedAt: Date.now(),
toolCallId: event.payload?.id ?? "", runId: event.runId,
name: event.payload?.name ?? "tool", arguments: event.payload?.arguments,
phase: "calling", startedAt: Date.now(), updatedAt: Date.now(),
};
toolStreamRef.current = [...toolStreamRef.current, entry];
setToolStream(toolStreamRef.current);
@@ -265,16 +149,9 @@ export function useChatMessages(sessionKey: string, agentId: string) {
const isError = event.payload?.is_error;
const resultId = event.payload?.id;
const now = Date.now();
// Update ref first so subsequent tool.call events don't overwrite with stale data.
toolStreamRef.current = toolStreamRef.current.map((t) =>
t.toolCallId === resultId
? {
...t,
phase: isError ? ("error" as const) : ("completed" as const),
errorContent: isError ? event.payload?.content : undefined,
result: event.payload?.result,
updatedAt: now,
}
? { ...t, phase: isError ? ("error" as const) : ("completed" as const), errorContent: isError ? event.payload?.content : undefined, result: event.payload?.result, updatedAt: now }
: t,
);
setToolStream(toolStreamRef.current);
@@ -283,12 +160,7 @@ export function useChatMessages(sessionKey: string, agentId: string) {
case "block.reply": {
const content = event.payload?.content ?? "";
if (content) {
const blockMsg: ChatMessage = {
role: "assistant",
content,
timestamp: Date.now(),
isBlockReply: true,
};
const blockMsg: ChatMessage = { role: "assistant", content, timestamp: Date.now(), isBlockReply: true };
blockRepliesRef.current = [...blockRepliesRef.current, blockMsg];
setBlockReplies(blockRepliesRef.current);
}
@@ -297,129 +169,54 @@ export function useChatMessages(sessionKey: string, agentId: string) {
case "activity": {
const phase = event.payload?.phase as RunActivity["phase"];
if (phase) {
const newActivity: RunActivity = {
phase,
tool: event.payload?.tool as string | undefined,
tools: event.payload?.tools as string[] | undefined,
iteration: event.payload?.iteration as number | undefined,
};
activityRef.current = newActivity;
setActivity(newActivity);
const newActivity: RunActivity = { phase, tool: event.payload?.tool as string | undefined, tools: event.payload?.tools as string[] | undefined, iteration: event.payload?.iteration as number | undefined };
activityRef.current = newActivity; setActivity(newActivity);
}
break;
}
case "run.retrying": {
const attempt = Number(event.payload?.attempt) || 0;
const maxAttempts = Number(event.payload?.maxAttempts) || 0;
activityRef.current = {
phase: "retrying",
retryAttempt: attempt,
retryMax: maxAttempts,
};
activityRef.current = { phase: "retrying", retryAttempt: Number(event.payload?.attempt) || 0, retryMax: Number(event.payload?.maxAttempts) || 0 };
setActivity(activityRef.current);
break;
}
case "run.completed": {
// Cancel any pending rAF to prevent stale state overwrite
cancelAnimationFrame(rafHandleRef.current);
rafPendingRef.current = false;
setIsRunning(false);
runIdRef.current = null;
// If we have streamed text and no tool calls, promote it directly
// to avoid a flash caused by loadHistory() replacing the DOM.
// When tools were used, reload to get structured tool_calls data.
cancelAnimationFrame(rafHandleRef.current); rafPendingRef.current = false;
setIsRunning(false); runIdRef.current = null;
const hadTools = toolStreamRef.current.length > 0;
const streamed = streamRef.current;
setStreamText(null);
setThinkingText(null);
setToolStream([]);
streamRef.current = "";
thinkingRef.current = "";
toolStreamRef.current = [];
activityRef.current = null;
setActivity(null);
blockRepliesRef.current = [];
setBlockReplies([]);
// Convert media from run.completed event to MediaItem[]
setStreamText(null); setThinkingText(null); setToolStream([]);
streamRef.current = ""; thinkingRef.current = ""; toolStreamRef.current = [];
activityRef.current = null; setActivity(null);
blockRepliesRef.current = []; setBlockReplies([]);
const rawMedia = event.payload?.media;
const mediaItems: MediaItem[] | undefined = rawMedia?.length
? rawMedia.map((m) => ({
path: toFileUrl(m.path),
mimeType: m.content_type ?? "application/octet-stream",
fileName: m.path.split("?")[0]?.split("/").pop() ?? "file",
size: m.size,
kind: mediaKindFromMime(m.content_type ?? ""),
}))
? rawMedia.map((m) => ({ path: toFileUrl(m.path), mimeType: m.content_type ?? "application/octet-stream", fileName: m.path.split("?")[0]?.split("/").pop() ?? "file", size: m.size, kind: mediaKindFromMime(m.content_type ?? "") }))
: undefined;
if (streamed && !hadTools) {
setMessages((prev) => [
...prev,
{ role: "assistant", content: streamed, timestamp: Date.now(), mediaItems },
]);
} else {
loadHistory(mediaItems);
}
setMessages((prev) => [...prev, { role: "assistant", content: streamed, timestamp: Date.now(), mediaItems }]);
} else { loadHistory(mediaItems); }
break;
}
case "run.failed": {
cancelAnimationFrame(rafHandleRef.current);
rafPendingRef.current = false;
setIsRunning(false);
runIdRef.current = null;
setStreamText(null);
setThinkingText(null);
setToolStream([]);
streamRef.current = "";
thinkingRef.current = "";
activityRef.current = null;
setActivity(null);
blockRepliesRef.current = [];
setBlockReplies([]);
setMessages((prev) => [
...prev,
{
role: "assistant",
content: `Error: ${event.payload?.error ?? "Unknown error"}`,
timestamp: Date.now(),
},
]);
cancelAnimationFrame(rafHandleRef.current); rafPendingRef.current = false;
setIsRunning(false); runIdRef.current = null;
setStreamText(null); setThinkingText(null); setToolStream([]);
streamRef.current = ""; thinkingRef.current = "";
activityRef.current = null; setActivity(null);
blockRepliesRef.current = []; setBlockReplies([]);
setMessages((prev) => [...prev, { role: "assistant", content: `Error: ${event.payload?.error ?? "Unknown error"}`, timestamp: Date.now() }]);
break;
}
// User-initiated cancellation — clear state, preserve partial content.
case "run.cancelled": {
cancelAnimationFrame(rafHandleRef.current);
rafPendingRef.current = false;
setIsRunning(false);
runIdRef.current = null;
cancelAnimationFrame(rafHandleRef.current); rafPendingRef.current = false;
setIsRunning(false); runIdRef.current = null;
const streamed = streamRef.current;
setStreamText(null);
setThinkingText(null);
setToolStream([]);
streamRef.current = "";
thinkingRef.current = "";
toolStreamRef.current = [];
activityRef.current = null;
setActivity(null);
blockRepliesRef.current = [];
setBlockReplies([]);
// Promote partial streamed text or reload history
if (streamed) {
setMessages((prev) => [
...prev,
{ role: "assistant", content: streamed, timestamp: Date.now() },
]);
} else {
loadHistory();
}
setStreamText(null); setThinkingText(null); setToolStream([]);
streamRef.current = ""; thinkingRef.current = ""; toolStreamRef.current = [];
activityRef.current = null; setActivity(null);
blockRepliesRef.current = []; setBlockReplies([]);
if (streamed) { setMessages((prev) => [...prev, { role: "assistant", content: streamed, timestamp: Date.now() }]); }
else { loadHistory(); }
break;
}
}
@@ -429,147 +226,7 @@ export function useChatMessages(sessionKey: string, agentId: string) {
useWsEvent(Events.AGENT, handleAgentEvent);
// Team task event handler (curried by event name)
const handleTeamTaskEvent = useCallback(
(eventName: string) => (payload: unknown) => {
const event = payload as {
team_id?: string;
task_id?: string;
task_number?: number;
subject?: string;
status?: string;
owner_agent_key?: string;
owner_display_name?: string;
progress_percent?: number;
progress_step?: string;
reason?: string;
channel?: string;
};
if (!event?.team_id) return;
// Only process team task events from WS channel (matches handleAgentEvent filter).
// Prevents channel events (Telegram, Discord, etc.) leaking into web UI chat.
if (event.channel && event.channel !== "ws") {
return;
}
setTeamTasks((prev) => {
const existing = prev.find((t) => t.taskId === event.task_id);
if (eventName === "team.task.dispatched" || eventName === "team.task.assigned") {
if (existing) {
return prev.map((t) =>
t.taskId === event.task_id
? { ...t, status: event.status ?? "in_progress", ownerAgentKey: event.owner_agent_key, ownerDisplayName: event.owner_display_name }
: t,
);
}
return [
...prev,
{
taskId: event.task_id ?? "",
taskNumber: event.task_number ?? 0,
subject: event.subject ?? "",
status: event.status ?? "in_progress",
ownerAgentKey: event.owner_agent_key,
ownerDisplayName: event.owner_display_name,
},
];
}
if (eventName === "team.task.progress" && existing) {
return prev.map((t) =>
t.taskId === event.task_id
? { ...t, progressPercent: event.progress_percent, progressStep: event.progress_step }
: t,
);
}
if (
eventName === "team.task.completed" ||
eventName === "team.task.failed" ||
eventName === "team.task.cancelled"
) {
return prev.filter((t) => t.taskId !== event.task_id);
}
// Increment counters for comment/attachment events
if (eventName === "team.task.commented" && existing) {
return prev.map((t) =>
t.taskId === event.task_id ? { ...t, commentCount: (t.commentCount ?? 0) + 1 } : t,
);
}
if (eventName === "team.task.attachment_added" && existing) {
return prev.map((t) =>
t.taskId === event.task_id ? { ...t, attachmentCount: (t.attachmentCount ?? 0) + 1 } : t,
);
}
return prev;
});
// Add inline notification message for key events
if (
eventName === "team.task.dispatched" ||
eventName === "team.task.completed" ||
eventName === "team.task.failed" ||
eventName === "team.task.commented" ||
eventName === "team.task.attachment_added"
) {
let icon = "📋";
let text = "";
if (eventName === "team.task.completed") {
icon = "✅";
text = `Task #${event.task_number} "${event.subject}" completed`;
} else if (eventName === "team.task.failed") {
icon = "❌";
text = `Task #${event.task_number} "${event.subject}" failed${event.reason ? ": " + event.reason : ""}`;
} else if (eventName === "team.task.commented") {
icon = "💬";
text = `Comment on #${event.task_number} "${event.subject}"`;
} else if (eventName === "team.task.attachment_added") {
icon = "📎";
text = `File attached to #${event.task_number} "${event.subject}"`;
} else {
icon = "📋";
text = `Task #${event.task_number} "${event.subject}" → ${event.owner_display_name || event.owner_agent_key}`;
}
setMessages((prev) => [
...prev,
{
role: "assistant" as const,
content: `${icon} ${text}`,
timestamp: Date.now(),
isNotification: true,
notificationType: eventName,
},
]);
}
},
[],
);
// Memoize bound handlers so useWsEvent doesn't re-register on every render.
const onTaskDispatched = useMemo(() => handleTeamTaskEvent("team.task.dispatched"), [handleTeamTaskEvent]);
const onTaskCompleted = useMemo(() => handleTeamTaskEvent("team.task.completed"), [handleTeamTaskEvent]);
const onTaskFailed = useMemo(() => handleTeamTaskEvent("team.task.failed"), [handleTeamTaskEvent]);
const onTaskCancelled = useMemo(() => handleTeamTaskEvent("team.task.cancelled"), [handleTeamTaskEvent]);
const onTaskProgress = useMemo(() => handleTeamTaskEvent("team.task.progress"), [handleTeamTaskEvent]);
const onTaskAssigned = useMemo(() => handleTeamTaskEvent("team.task.assigned"), [handleTeamTaskEvent]);
const onTaskCommented = useMemo(() => handleTeamTaskEvent("team.task.commented"), [handleTeamTaskEvent]);
const onTaskAttached = useMemo(() => handleTeamTaskEvent("team.task.attachment_added"), [handleTeamTaskEvent]);
useWsEvent(Events.TEAM_TASK_DISPATCHED, onTaskDispatched);
useWsEvent(Events.TEAM_TASK_COMPLETED, onTaskCompleted);
useWsEvent(Events.TEAM_TASK_FAILED, onTaskFailed);
useWsEvent(Events.TEAM_TASK_CANCELLED, onTaskCancelled);
useWsEvent(Events.TEAM_TASK_PROGRESS, onTaskProgress);
useWsEvent(Events.TEAM_TASK_ASSIGNED, onTaskAssigned);
useWsEvent(Events.TEAM_TASK_COMMENTED, onTaskCommented);
useWsEvent(Events.TEAM_TASK_ATTACHMENT_ADDED, onTaskAttached);
// Leader processing: backend emits when announce queue drains (before announce run starts).
// Leader processing: backend emits when announce queue drains
const handleLeaderProcessing = useCallback((payload: unknown) => {
const event = payload as { agentId?: string; tasks?: number };
if (event?.agentId === agentIdRef.current) {
@@ -579,28 +236,10 @@ export function useChatMessages(sessionKey: string, agentId: string) {
}, []);
useWsEvent(Events.TEAM_LEADER_PROCESSING, handleLeaderProcessing);
// Add a local message optimistically (shown immediately, replaced on next loadHistory)
const addLocalMessage = useCallback((msg: ChatMessage) => {
setMessages((prev) => [...prev, msg]);
}, []);
// isBusy: true when main agent is running, team tasks are active,
// or leader is processing team results before announce run starts.
const isBusy = isRunning || teamTasks.length > 0 || activity?.phase === "leader_processing";
return {
messages,
streamText,
thinkingText,
toolStream,
isRunning,
isBusy,
loading,
activity,
blockReplies,
teamTasks,
expectRun,
loadHistory,
addLocalMessage,
messages, streamText, thinkingText, toolStream, isRunning, isBusy,
loading, activity, blockReplies, teamTasks, expectRun, loadHistory, addLocalMessage,
};
}
@@ -0,0 +1,134 @@
import { useState, useCallback, useMemo } from "react";
import { useWsEvent } from "@/hooks/use-ws-event";
import { Events } from "@/api/protocol";
import type { ChatMessage, ActiveTeamTask } from "@/types/chat";
/**
* Manages active team task state and inline notification messages
* from team task WebSocket events for a chat session.
*/
export function useChatTeamTasks(
addMessage: (msg: ChatMessage) => void,
) {
const [teamTasks, setTeamTasks] = useState<ActiveTeamTask[]>([]);
// Team task event handler (curried by event name)
const handleTeamTaskEvent = useCallback(
(eventName: string) => (payload: unknown) => {
const event = payload as {
team_id?: string;
task_id?: string;
task_number?: number;
subject?: string;
status?: string;
owner_agent_key?: string;
owner_display_name?: string;
progress_percent?: number;
progress_step?: string;
reason?: string;
channel?: string;
};
if (!event?.team_id) return;
// Only process team task events from WS channel
if (event.channel && event.channel !== "ws") return;
setTeamTasks((prev) => {
const existing = prev.find((t) => t.taskId === event.task_id);
if (eventName === "team.task.dispatched" || eventName === "team.task.assigned") {
if (existing) {
return prev.map((t) =>
t.taskId === event.task_id
? { ...t, status: event.status ?? "in_progress", ownerAgentKey: event.owner_agent_key, ownerDisplayName: event.owner_display_name }
: t,
);
}
return [
...prev,
{
taskId: event.task_id ?? "",
taskNumber: event.task_number ?? 0,
subject: event.subject ?? "",
status: event.status ?? "in_progress",
ownerAgentKey: event.owner_agent_key,
ownerDisplayName: event.owner_display_name,
},
];
}
if (eventName === "team.task.progress" && existing) {
return prev.map((t) =>
t.taskId === event.task_id
? { ...t, progressPercent: event.progress_percent, progressStep: event.progress_step }
: t,
);
}
if (eventName === "team.task.completed" || eventName === "team.task.failed" || eventName === "team.task.cancelled") {
return prev.filter((t) => t.taskId !== event.task_id);
}
if (eventName === "team.task.commented" && existing) {
return prev.map((t) =>
t.taskId === event.task_id ? { ...t, commentCount: (t.commentCount ?? 0) + 1 } : t,
);
}
if (eventName === "team.task.attachment_added" && existing) {
return prev.map((t) =>
t.taskId === event.task_id ? { ...t, attachmentCount: (t.attachmentCount ?? 0) + 1 } : t,
);
}
return prev;
});
// Add inline notification message for key events
if (
eventName === "team.task.dispatched" ||
eventName === "team.task.completed" ||
eventName === "team.task.failed" ||
eventName === "team.task.commented" ||
eventName === "team.task.attachment_added"
) {
let icon = "📋";
let text = "";
if (eventName === "team.task.completed") { icon = "✅"; text = `Task #${event.task_number} "${event.subject}" completed`; }
else if (eventName === "team.task.failed") { icon = "❌"; text = `Task #${event.task_number} "${event.subject}" failed${event.reason ? ": " + event.reason : ""}`; }
else if (eventName === "team.task.commented") { icon = "💬"; text = `Comment on #${event.task_number} "${event.subject}"`; }
else if (eventName === "team.task.attachment_added") { icon = "📎"; text = `File attached to #${event.task_number} "${event.subject}"`; }
else { icon = "📋"; text = `Task #${event.task_number} "${event.subject}" → ${event.owner_display_name || event.owner_agent_key}`; }
addMessage({
role: "assistant" as const,
content: `${icon} ${text}`,
timestamp: Date.now(),
isNotification: true,
notificationType: eventName,
});
}
},
[addMessage],
);
// Memoize bound handlers so useWsEvent doesn't re-register on every render
const onTaskDispatched = useMemo(() => handleTeamTaskEvent("team.task.dispatched"), [handleTeamTaskEvent]);
const onTaskCompleted = useMemo(() => handleTeamTaskEvent("team.task.completed"), [handleTeamTaskEvent]);
const onTaskFailed = useMemo(() => handleTeamTaskEvent("team.task.failed"), [handleTeamTaskEvent]);
const onTaskCancelled = useMemo(() => handleTeamTaskEvent("team.task.cancelled"), [handleTeamTaskEvent]);
const onTaskProgress = useMemo(() => handleTeamTaskEvent("team.task.progress"), [handleTeamTaskEvent]);
const onTaskAssigned = useMemo(() => handleTeamTaskEvent("team.task.assigned"), [handleTeamTaskEvent]);
const onTaskCommented = useMemo(() => handleTeamTaskEvent("team.task.commented"), [handleTeamTaskEvent]);
const onTaskAttached = useMemo(() => handleTeamTaskEvent("team.task.attachment_added"), [handleTeamTaskEvent]);
useWsEvent(Events.TEAM_TASK_DISPATCHED, onTaskDispatched);
useWsEvent(Events.TEAM_TASK_COMPLETED, onTaskCompleted);
useWsEvent(Events.TEAM_TASK_FAILED, onTaskFailed);
useWsEvent(Events.TEAM_TASK_CANCELLED, onTaskCancelled);
useWsEvent(Events.TEAM_TASK_PROGRESS, onTaskProgress);
useWsEvent(Events.TEAM_TASK_ASSIGNED, onTaskAssigned);
useWsEvent(Events.TEAM_TASK_COMMENTED, onTaskCommented);
useWsEvent(Events.TEAM_TASK_ATTACHMENT_ADDED, onTaskAttached);
return { teamTasks, setTeamTasks };
}
@@ -0,0 +1,126 @@
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { Plus, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import type { CLIPreset } from "./hooks/use-cli-credentials";
export interface ManualEnvEntry {
key: string;
value: string;
}
interface CliCredentialEnvVarsSectionProps {
isManualMode: boolean;
activePreset: CLIPreset | null;
envValues: Record<string, string>;
setEnvValues: (updater: (prev: Record<string, string>) => Record<string, string>) => void;
manualEnvEntries: ManualEnvEntry[];
setManualEnvEntries: (updater: (prev: ManualEnvEntry[]) => ManualEnvEntry[]) => void;
}
/** Env var inputs: preset-driven fields or free-form key/value pairs in manual mode. */
export function CliCredentialEnvVarsSection({
isManualMode,
activePreset,
envValues,
setEnvValues,
manualEnvEntries,
setManualEnvEntries,
}: CliCredentialEnvVarsSectionProps) {
const { t } = useTranslation("cli-credentials");
const { t: tc } = useTranslation("common");
const addEntry = useCallback(() => {
setManualEnvEntries((prev) => [...prev, { key: "", value: "" }]);
}, [setManualEnvEntries]);
const removeEntry = useCallback((index: number) => {
setManualEnvEntries((prev) => prev.filter((_, i) => i !== index));
}, [setManualEnvEntries]);
const updateEntry = useCallback((index: number, field: "key" | "value", val: string) => {
setManualEnvEntries((prev) =>
prev.map((entry, i) => (i === index ? { ...entry, [field]: val } : entry)),
);
}, [setManualEnvEntries]);
// Preset-driven env var inputs
if (!isManualMode && activePreset && activePreset.env_vars.length > 0) {
return (
<div className="grid gap-3 rounded-md border p-3">
<p className="text-sm font-medium">{t("form.envVars")}</p>
{activePreset.env_vars.map((ev) => (
<div key={ev.name} className="grid gap-1.5">
<Label htmlFor={`env-${ev.name}`}>
{ev.name}
{ev.optional && <span className="ml-1 text-xs text-muted-foreground">({tc("optional")})</span>}
</Label>
<Input
id={`env-${ev.name}`}
type="password"
autoComplete="off"
placeholder={ev.desc}
value={envValues[ev.name] ?? ""}
onChange={(e) => setEnvValues((prev) => ({ ...prev, [ev.name]: e.target.value }))}
className="text-base md:text-sm"
/>
{ev.desc && <p className="text-xs text-muted-foreground">{ev.desc}</p>}
</div>
))}
</div>
);
}
// Manual free-form key/value entries
if (isManualMode) {
return (
<div className="grid gap-3 rounded-md border p-3">
<div className="flex items-center justify-between">
<p className="text-sm font-medium">{t("form.envVars")}</p>
<Button type="button" variant="outline" size="sm" onClick={addEntry}>
<Plus className="mr-1 h-3.5 w-3.5" />
{t("form.addEnvVar")}
</Button>
</div>
{manualEnvEntries.length === 0 && (
<p className="text-xs text-muted-foreground">{t("form.noEnvVarsHint")}</p>
)}
{manualEnvEntries.map((entry, idx) => (
<div key={idx} className="flex items-start gap-2">
<div className="grid flex-1 gap-1.5">
<Input
placeholder={t("form.envKeyPlaceholder")}
value={entry.key}
onChange={(e) => updateEntry(idx, "key", e.target.value)}
className="text-base md:text-sm font-mono"
/>
</div>
<div className="grid flex-1 gap-1.5">
<Input
type="password"
autoComplete="off"
placeholder={t("form.envValuePlaceholder")}
value={entry.value}
onChange={(e) => updateEntry(idx, "value", e.target.value)}
className="text-base md:text-sm"
/>
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="mt-0.5 h-8 w-8 shrink-0"
onClick={() => removeEntry(idx)}
>
<X className="h-4 w-4" />
</Button>
</div>
))}
</div>
);
}
return null;
}
@@ -1,4 +1,5 @@
import { useState, useEffect, useCallback } from "react";
import type { ManualEnvEntry } from "./cli-credential-env-vars-section";
import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import {
Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle,
@@ -11,15 +12,12 @@ import { Switch } from "@/components/ui/switch";
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "@/components/ui/select";
import { Plus, X, Search, Check, AlertCircle } from "lucide-react";
import { Search, Check, AlertCircle } from "lucide-react";
import { useHttp } from "@/hooks/use-ws";
import { useAgents } from "@/pages/agents/hooks/use-agents";
import type { SecureCLIBinary, CLICredentialInput, CLIPreset } from "./hooks/use-cli-credentials";
import { CliCredentialEnvVarsSection } from "./cli-credential-env-vars-section";
interface ManualEnvEntry {
key: string;
value: string;
}
interface Props {
open: boolean;
@@ -31,6 +29,7 @@ interface Props {
const NONE_PRESET = "__none__";
const GLOBAL_AGENT = "__global__";
const ENV_KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
export function CliCredentialFormDialog({ open, onOpenChange, credential, presets, onSubmit }: Props) {
const { t } = useTranslation("cli-credentials");
@@ -50,12 +49,9 @@ export function CliCredentialFormDialog({ open, onOpenChange, credential, preset
const [enabled, setEnabled] = useState(true);
const [envValues, setEnvValues] = useState<Record<string, string>>({});
const [manualEnvEntries, setManualEnvEntries] = useState<ManualEnvEntry[]>([]);
/** Snapshot when dialog opens (edit) — detect clearing all env rows */
const [initialEnvKeys, setInitialEnvKeys] = useState<string[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
// Check binary state
const [checking, setChecking] = useState(false);
const [checkResult, setCheckResult] = useState<{ found: boolean; path?: string; error?: string } | null>(null);
@@ -63,10 +59,7 @@ export function CliCredentialFormDialog({ open, onOpenChange, credential, preset
const presetEntries: Array<[string, CLIPreset]> = Object.entries(presets).filter(
(e): e is [string, CLIPreset] => e[1] !== undefined,
);
const activePreset: CLIPreset | null =
selectedPreset !== NONE_PRESET ? (presets[selectedPreset] ?? null) : null;
const activePreset: CLIPreset | null = selectedPreset !== NONE_PRESET ? (presets[selectedPreset] ?? null) : null;
const isManualMode = selectedPreset === NONE_PRESET;
useEffect(() => {
@@ -111,9 +104,7 @@ export function CliCredentialFormDialog({ open, onOpenChange, credential, preset
if (!cancelled) applyEnvKeys([]);
}
})();
return () => {
cancelled = true;
};
return () => { cancelled = true; };
}, [open, credential, http]);
const applyPreset = (key: string) => {
@@ -142,9 +133,7 @@ export function CliCredentialFormDialog({ open, onOpenChange, credential, preset
{ binary_name: name },
);
setCheckResult(res);
if (res.found && res.path) {
setBinaryPath(res.path);
}
if (res.found && res.path) setBinaryPath(res.path);
} catch {
setCheckResult({ found: false, error: t("form.binaryNotFound") });
} finally {
@@ -152,25 +141,9 @@ export function CliCredentialFormDialog({ open, onOpenChange, credential, preset
}
};
const addManualEnvEntry = useCallback(() => {
setManualEnvEntries((prev) => [...prev, { key: "", value: "" }]);
}, []);
const removeManualEnvEntry = useCallback((index: number) => {
setManualEnvEntries((prev) => prev.filter((_, i) => i !== index));
}, []);
const updateManualEnvEntry = useCallback((index: number, field: "key" | "value", val: string) => {
setManualEnvEntries((prev) =>
prev.map((entry, i) => (i === index ? { ...entry, [field]: val } : entry)),
);
}, []);
const splitCommaList = (v: string): string[] =>
v.split(",").map((s) => s.trim()).filter(Boolean);
const ENV_KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
const buildEnvPayload = (): Record<string, string> | null => {
if (!isManualMode) return envValues;
const env: Record<string, string> = {};
@@ -186,10 +159,7 @@ export function CliCredentialFormDialog({ open, onOpenChange, credential, preset
};
const handleSubmit = async () => {
if (!binaryName.trim()) {
setError(t("form.binaryNameRequired"));
return;
}
if (!binaryName.trim()) { setError(t("form.binaryNameRequired")); return; }
setLoading(true);
setError("");
try {
@@ -246,92 +216,24 @@ export function CliCredentialFormDialog({ open, onOpenChange, credential, preset
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{t("form.presetHint")}
</p>
<p className="text-xs text-muted-foreground">{t("form.presetHint")}</p>
</div>
)}
{/* Existing credentials indicator (edit mode) */}
{isEdit && (
<p className="text-xs text-muted-foreground rounded-md border border-dashed p-2">
{t("form.encryptedHint")}
</p>
)}
{/* Env var inputs from preset */}
{activePreset && activePreset.env_vars.length > 0 && (
<div className="grid gap-3 rounded-md border p-3">
<p className="text-sm font-medium">{t("form.envVars")}</p>
{activePreset.env_vars.map((ev) => (
<div key={ev.name} className="grid gap-1.5">
<Label htmlFor={`env-${ev.name}`}>
{ev.name}
{ev.optional && <span className="ml-1 text-xs text-muted-foreground">({tc("optional")})</span>}
</Label>
<Input
id={`env-${ev.name}`}
type="password"
autoComplete="off"
placeholder={ev.desc}
value={envValues[ev.name] ?? ""}
onChange={(e) => setEnvValues((prev) => ({ ...prev, [ev.name]: e.target.value }))}
className="text-base md:text-sm"
/>
{ev.desc && (
<p className="text-xs text-muted-foreground">{ev.desc}</p>
)}
</div>
))}
</div>
)}
{/* Manual env var entries (no preset selected) */}
{isManualMode && (
<div className="grid gap-3 rounded-md border p-3">
<div className="flex items-center justify-between">
<p className="text-sm font-medium">{t("form.envVars")}</p>
<Button type="button" variant="outline" size="sm" onClick={addManualEnvEntry}>
<Plus className="mr-1 h-3.5 w-3.5" />
{t("form.addEnvVar")}
</Button>
</div>
{manualEnvEntries.length === 0 && (
<p className="text-xs text-muted-foreground">{t("form.noEnvVarsHint")}</p>
)}
{manualEnvEntries.map((entry, idx) => (
<div key={idx} className="flex items-start gap-2">
<div className="grid flex-1 gap-1.5">
<Input
placeholder={t("form.envKeyPlaceholder")}
value={entry.key}
onChange={(e) => updateManualEnvEntry(idx, "key", e.target.value)}
className="text-base md:text-sm font-mono"
/>
</div>
<div className="grid flex-1 gap-1.5">
<Input
type="password"
autoComplete="off"
placeholder={t("form.envValuePlaceholder")}
value={entry.value}
onChange={(e) => updateManualEnvEntry(idx, "value", e.target.value)}
className="text-base md:text-sm"
/>
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="mt-0.5 h-8 w-8 shrink-0"
onClick={() => removeManualEnvEntry(idx)}
>
<X className="h-4 w-4" />
</Button>
</div>
))}
</div>
)}
<CliCredentialEnvVarsSection
isManualMode={isManualMode}
activePreset={activePreset}
envValues={envValues}
setEnvValues={setEnvValues}
manualEnvEntries={manualEnvEntries}
setManualEnvEntries={setManualEnvEntries}
/>
{/* Binary name + check button */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
@@ -360,13 +262,17 @@ export function CliCredentialFormDialog({ open, onOpenChange, credential, preset
{checkResult && (
<p className={`text-xs flex items-center gap-1 ${checkResult.found ? "text-green-600 dark:text-green-400" : "text-destructive"}`}>
{checkResult.found ? <Check className="h-3 w-3" /> : <AlertCircle className="h-3 w-3" />}
{checkResult.found ? t("form.binaryFound", { path: checkResult.path }) : (checkResult.error || t("form.binaryNotFound"))}
{checkResult.found
? t("form.binaryFound", { path: checkResult.path })
: (checkResult.error || t("form.binaryNotFound"))}
</p>
)}
{checking && <p className="text-xs text-muted-foreground">{t("form.checking")}</p>}
</div>
<div className="grid gap-1.5">
<Label htmlFor="cc-path">{t("form.binaryPath")} <span className="text-xs text-muted-foreground">({tc("optional")})</span></Label>
<Label htmlFor="cc-path">
{t("form.binaryPath")} <span className="text-xs text-muted-foreground">({tc("optional")})</span>
</Label>
<Input
id="cc-path"
value={binaryPath}
@@ -392,7 +298,9 @@ export function CliCredentialFormDialog({ open, onOpenChange, credential, preset
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="grid gap-1.5">
<Label htmlFor="cc-deny-args">{t("form.denyArgs")} <span className="text-xs text-muted-foreground">({t("form.commaSeparated")})</span></Label>
<Label htmlFor="cc-deny-args">
{t("form.denyArgs")} <span className="text-xs text-muted-foreground">({t("form.commaSeparated")})</span>
</Label>
<Input
id="cc-deny-args"
value={denyArgs}
@@ -415,7 +323,9 @@ export function CliCredentialFormDialog({ open, onOpenChange, credential, preset
</div>
<div className="grid gap-1.5">
<Label htmlFor="cc-deny-verbose">{t("form.denyVerbose")} <span className="text-xs text-muted-foreground">({t("form.commaSeparated")})</span></Label>
<Label htmlFor="cc-deny-verbose">
{t("form.denyVerbose")} <span className="text-xs text-muted-foreground">({t("form.commaSeparated")})</span>
</Label>
<Input
id="cc-deny-verbose"
value={denyVerbose}
@@ -437,9 +347,10 @@ export function CliCredentialFormDialog({ open, onOpenChange, credential, preset
/>
</div>
{/* Agent selector */}
<div className="grid gap-1.5">
<Label>{t("form.agentId")} <span className="text-xs text-muted-foreground">({t("form.agentIdHint")})</span></Label>
<Label>
{t("form.agentId")} <span className="text-xs text-muted-foreground">({t("form.agentIdHint")})</span>
</Label>
<Select value={agentId || GLOBAL_AGENT} onValueChange={(v) => setAgentId(v === GLOBAL_AGENT ? "" : v)}>
<SelectTrigger className="text-base md:text-sm">
<SelectValue placeholder={t("placeholders.agentId")} />
@@ -0,0 +1,73 @@
/**
* Reusable form controls for the AI defaults section:
* - SubSection: collapsible group panel with title and description
* - Field: labeled input with optional InfoLabel tooltip
*/
import { ChevronDown, ChevronRight } from "lucide-react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { InfoLabel } from "@/components/shared/info-label";
/* eslint-disable @typescript-eslint/no-explicit-any */
export function SubSection({
title,
desc,
open,
onToggle,
children,
}: {
title: string;
desc: string;
open: boolean;
onToggle: () => void;
children: React.ReactNode;
}) {
return (
<div className="rounded-md border">
<button
type="button"
className="flex w-full cursor-pointer items-center gap-2 px-3 py-2.5 text-left text-sm hover:bg-muted/50"
onClick={onToggle}
>
{open ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
<div>
<span className="font-medium">{title}</span>
<span className="ml-2 text-xs text-muted-foreground">{desc}</span>
</div>
</button>
{open && <div className="space-y-3 border-t px-4 py-3">{children}</div>}
</div>
);
}
export function Field({
label,
tip,
value,
onChange,
placeholder,
type = "text",
step,
}: {
label: string;
tip?: string;
value: any;
onChange: (v: string) => void;
placeholder?: string;
type?: string;
step?: string;
}) {
return (
<div className="grid gap-1.5">
{tip ? <InfoLabel tip={tip}>{label}</InfoLabel> : <Label>{label}</Label>}
<Input
type={type}
step={step}
value={value ?? ""}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
/>
</div>
);
}
@@ -1,5 +1,5 @@
import { useState, useEffect } from "react";
import { Save, ChevronDown, ChevronRight, Loader2 } from "lucide-react";
import { Save, Loader2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -9,6 +9,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
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";
import { SubSection, Field } from "./ai-defaults-form-controls";
/* eslint-disable @typescript-eslint/no-explicit-any */
type AgentsData = Record<string, any>;
@@ -263,66 +264,3 @@ export function AiDefaultsSection({ data, onSave, saving }: Props) {
);
}
/* --- Helper components --- */
function SubSection({
title,
desc,
open,
onToggle,
children,
}: {
title: string;
desc: string;
open: boolean;
onToggle: () => void;
children: React.ReactNode;
}) {
return (
<div className="rounded-md border">
<button
type="button"
className="flex w-full cursor-pointer items-center gap-2 px-3 py-2.5 text-left text-sm hover:bg-muted/50"
onClick={onToggle}
>
{open ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
<div>
<span className="font-medium">{title}</span>
<span className="ml-2 text-xs text-muted-foreground">{desc}</span>
</div>
</button>
{open && <div className="space-y-3 border-t px-4 py-3">{children}</div>}
</div>
);
}
function Field({
label,
tip,
value,
onChange,
placeholder,
type = "text",
step,
}: {
label: string;
tip?: string;
value: any; // eslint-disable-line @typescript-eslint/no-explicit-any
onChange: (v: string) => void;
placeholder?: string;
type?: string;
step?: string;
}) {
return (
<div className="grid gap-1.5">
{tip ? <InfoLabel tip={tip}>{label}</InfoLabel> : <Label>{label}</Label>}
<Input
type={type}
step={step}
value={value ?? ""}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
/>
</div>
);
}
@@ -0,0 +1,216 @@
import { useTranslation } from "react-i18next";
import { Plus, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { InfoLabel } from "@/components/shared/info-label";
export interface QuotaWindow {
hour?: number;
day?: number;
week?: number;
}
interface QuotaWindowInputsProps {
value: QuotaWindow;
onChange: (v: QuotaWindow) => void;
}
export function QuotaWindowInputs({ value, onChange }: QuotaWindowInputsProps) {
const { t } = useTranslation("config");
return (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
<div className="grid gap-1.5">
<InfoLabel tip={t("quota.hourTip")}>{t("quota.hour")}</InfoLabel>
<Input
type="number"
min={0}
value={value.hour ?? 0}
onChange={(e) => onChange({ ...value, hour: Number(e.target.value) })}
/>
</div>
<div className="grid gap-1.5">
<InfoLabel tip={t("quota.dayTip")}>{t("quota.day")}</InfoLabel>
<Input
type="number"
min={0}
value={value.day ?? 0}
onChange={(e) => onChange({ ...value, day: Number(e.target.value) })}
/>
</div>
<div className="grid gap-1.5">
<InfoLabel tip={t("quota.weekTip")}>{t("quota.week")}</InfoLabel>
<Input
type="number"
min={0}
value={value.week ?? 0}
onChange={(e) => onChange({ ...value, week: Number(e.target.value) })}
/>
</div>
</div>
);
}
interface OverridesTableProps {
label: string;
tip: string;
entries: Record<string, QuotaWindow>;
onChange: (v: Record<string, QuotaWindow>) => void;
keyPlaceholder: string;
options?: { value: string; label: string }[];
}
export function OverridesTable({
label,
tip,
entries,
onChange,
keyPlaceholder,
options,
}: OverridesTableProps) {
const { t } = useTranslation("config");
const keys = Object.keys(entries);
const usedKeys = new Set(keys);
const availableOptions = options?.filter((o) => !usedKeys.has(o.value));
const addRow = (key?: string) => {
const newKey = key ?? "";
if (newKey in entries) return;
onChange({ ...entries, [newKey]: { hour: 0, day: 0, week: 0 } });
};
const removeRow = (key: string) => {
const next = { ...entries };
delete next[key];
onChange(next);
};
const updateKey = (oldKey: string, newKey: string) => {
if (newKey !== oldKey && newKey in entries) return;
const next: Record<string, QuotaWindow> = {};
for (const [k, v] of Object.entries(entries)) {
next[k === oldKey ? newKey : k] = v;
}
onChange(next);
};
const updateWindow = (key: string, window: QuotaWindow) => {
onChange({ ...entries, [key]: window });
};
return (
<div className="space-y-2">
<InfoLabel tip={tip}>{label}</InfoLabel>
{keys.map((key, i) => (
<div key={i} className="overflow-x-auto">
<div className="flex items-end gap-2 min-w-[420px]">
<div className="grid gap-1.5 min-w-[180px]">
{i === 0 && (
<span className="text-xs text-muted-foreground">{t("quota.keyLabel")}</span>
)}
{options ? (
<Select value={key} onValueChange={(v) => updateKey(key, v)}>
<SelectTrigger>
<SelectValue placeholder={keyPlaceholder} />
</SelectTrigger>
<SelectContent>
{key && (
<SelectItem value={key}>
{options.find((o) => o.value === key)?.label ?? key}
</SelectItem>
)}
{availableOptions
?.filter((o) => o.value !== key)
.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
placeholder={keyPlaceholder}
value={key}
onChange={(e) => updateKey(key, e.target.value)}
/>
)}
</div>
<div className="grid gap-1.5 w-20">
{i === 0 && (
<span className="text-xs text-muted-foreground">{t("quota.hour")}</span>
)}
<Input
type="number"
min={0}
value={entries[key]?.hour ?? 0}
onChange={(e) => updateWindow(key, { ...entries[key], hour: Number(e.target.value) })}
/>
</div>
<div className="grid gap-1.5 w-20">
{i === 0 && (
<span className="text-xs text-muted-foreground">{t("quota.day")}</span>
)}
<Input
type="number"
min={0}
value={entries[key]?.day ?? 0}
onChange={(e) => updateWindow(key, { ...entries[key], day: Number(e.target.value) })}
/>
</div>
<div className="grid gap-1.5 w-20">
{i === 0 && (
<span className="text-xs text-muted-foreground">{t("quota.week")}</span>
)}
<Input
type="number"
min={0}
value={entries[key]?.week ?? 0}
onChange={(e) => updateWindow(key, { ...entries[key], week: Number(e.target.value) })}
/>
</div>
<Button
variant="ghost"
size="icon"
className="shrink-0"
onClick={() => removeRow(key)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
))}
{options ? (
<Select value="" onValueChange={(v) => addRow(v)}>
<SelectTrigger className="w-auto gap-1.5" size="sm">
<Plus className="h-3.5 w-3.5" />
<SelectValue placeholder={t("quota.addOverride")} />
</SelectTrigger>
<SelectContent>
{availableOptions && availableOptions.length > 0 ? (
availableOptions.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))
) : (
<SelectItem value="__none__" disabled>
{t("quota.allOptionsAdded")}
</SelectItem>
)}
</SelectContent>
</Select>
) : (
<Button variant="outline" size="sm" onClick={() => addRow()} className="gap-1.5">
<Plus className="h-3.5 w-3.5" /> {t("quota.addOverride")}
</Button>
)}
</div>
);
}
@@ -1,16 +1,8 @@
import { useState, useEffect } from "react";
import { Save, Plus, Trash2 } from "lucide-react";
import { Save } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Card,
CardContent,
@@ -21,12 +13,7 @@ import {
import { InfoLabel } from "@/components/shared/info-label";
import { useProviders } from "@/pages/providers/hooks/use-providers";
import { useChannelInstances } from "@/pages/channels/hooks/use-channel-instances";
interface QuotaWindow {
hour?: number;
day?: number;
week?: number;
}
import { QuotaWindowInputs, OverridesTable, type QuotaWindow } from "./quota-overrides-table";
interface QuotaData {
enabled: boolean;
@@ -47,225 +34,6 @@ interface Props {
saving: boolean;
}
function QuotaWindowInputs({
value,
onChange,
}: {
value: QuotaWindow;
onChange: (v: QuotaWindow) => void;
}) {
const { t } = useTranslation("config");
return (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
<div className="grid gap-1.5">
<InfoLabel tip={t("quota.hourTip")}>{t("quota.hour")}</InfoLabel>
<Input
type="number"
min={0}
value={value.hour ?? 0}
onChange={(e) => onChange({ ...value, hour: Number(e.target.value) })}
/>
</div>
<div className="grid gap-1.5">
<InfoLabel tip={t("quota.dayTip")}>{t("quota.day")}</InfoLabel>
<Input
type="number"
min={0}
value={value.day ?? 0}
onChange={(e) => onChange({ ...value, day: Number(e.target.value) })}
/>
</div>
<div className="grid gap-1.5">
<InfoLabel tip={t("quota.weekTip")}>{t("quota.week")}</InfoLabel>
<Input
type="number"
min={0}
value={value.week ?? 0}
onChange={(e) => onChange({ ...value, week: Number(e.target.value) })}
/>
</div>
</div>
);
}
function OverridesTable({
label,
tip,
entries,
onChange,
keyPlaceholder,
options,
}: {
label: string;
tip: string;
entries: Record<string, QuotaWindow>;
onChange: (v: Record<string, QuotaWindow>) => void;
keyPlaceholder: string;
options?: { value: string; label: string }[];
}) {
const keys = Object.keys(entries);
const usedKeys = new Set(keys);
const availableOptions = options?.filter((o) => !usedKeys.has(o.value));
const addRow = (key?: string) => {
const newKey = key ?? "";
if (newKey in entries) return;
onChange({ ...entries, [newKey]: { hour: 0, day: 0, week: 0 } });
};
const removeRow = (key: string) => {
const next = { ...entries };
delete next[key];
onChange(next);
};
const updateKey = (oldKey: string, newKey: string) => {
if (newKey !== oldKey && newKey in entries) return;
const next: Record<string, QuotaWindow> = {};
for (const [k, v] of Object.entries(entries)) {
next[k === oldKey ? newKey : k] = v;
}
onChange(next);
};
const updateWindow = (key: string, window: QuotaWindow) => {
onChange({ ...entries, [key]: window });
};
const { t } = useTranslation("config");
return (
<div className="space-y-2">
<InfoLabel tip={tip}>{label}</InfoLabel>
{keys.map((key, i) => (
<div key={i} className="overflow-x-auto">
<div className="flex items-end gap-2 min-w-[420px]">
<div className="grid gap-1.5 min-w-[180px]">
{i === 0 && (
<span className="text-xs text-muted-foreground">{t("quota.keyLabel")}</span>
)}
{options ? (
<Select
value={key}
onValueChange={(v) => updateKey(key, v)}
>
<SelectTrigger>
<SelectValue placeholder={keyPlaceholder} />
</SelectTrigger>
<SelectContent>
{/* Current value always shown */}
{key && (
<SelectItem value={key}>
{options.find((o) => o.value === key)?.label ?? key}
</SelectItem>
)}
{availableOptions
?.filter((o) => o.value !== key)
.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
placeholder={keyPlaceholder}
value={key}
onChange={(e) => updateKey(key, e.target.value)}
/>
)}
</div>
<div className="grid gap-1.5 w-20">
{i === 0 && (
<span className="text-xs text-muted-foreground">{t("quota.hour")}</span>
)}
<Input
type="number"
min={0}
value={entries[key]?.hour ?? 0}
onChange={(e) =>
updateWindow(key, {
...entries[key],
hour: Number(e.target.value),
})
}
/>
</div>
<div className="grid gap-1.5 w-20">
{i === 0 && (
<span className="text-xs text-muted-foreground">{t("quota.day")}</span>
)}
<Input
type="number"
min={0}
value={entries[key]?.day ?? 0}
onChange={(e) =>
updateWindow(key, {
...entries[key],
day: Number(e.target.value),
})
}
/>
</div>
<div className="grid gap-1.5 w-20">
{i === 0 && (
<span className="text-xs text-muted-foreground">{t("quota.week")}</span>
)}
<Input
type="number"
min={0}
value={entries[key]?.week ?? 0}
onChange={(e) =>
updateWindow(key, {
...entries[key],
week: Number(e.target.value),
})
}
/>
</div>
<Button
variant="ghost"
size="icon"
className="shrink-0"
onClick={() => removeRow(key)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
))}
{options ? (
<Select
value=""
onValueChange={(v) => addRow(v)}
>
<SelectTrigger className="w-auto gap-1.5" size="sm">
<Plus className="h-3.5 w-3.5" />
<SelectValue placeholder={t("quota.addOverride")} />
</SelectTrigger>
<SelectContent>
{availableOptions && availableOptions.length > 0 ? (
availableOptions.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))
) : (
<SelectItem value="__none__" disabled>
{t("quota.allOptionsAdded")}
</SelectItem>
)}
</SelectContent>
</Select>
) : (
<Button variant="outline" size="sm" onClick={() => addRow()} className="gap-1.5">
<Plus className="h-3.5 w-3.5" /> {t("quota.addOverride")}
</Button>
)}
</div>
);
}
export function QuotaSection({ data, onSave, saving }: Props) {
const { t } = useTranslation("config");
+14 -171
View File
@@ -1,7 +1,6 @@
import { useState, useEffect, useMemo, useRef, useLayoutEffect } from "react";
import { useState, useEffect, useMemo, useRef } from "react";
import { useTranslation } from "react-i18next";
import { createPortal } from "react-dom";
import { Trash2, Plus, X, ChevronDownIcon, Pencil } from "lucide-react";
import { Trash2, Plus, Pencil } from "lucide-react";
import {
Dialog,
DialogContent,
@@ -21,6 +20,7 @@ import {
import { cn, uniqueId } from "@/lib/utils";
import { useAgents } from "@/pages/agents/hooks/use-agents";
import type { MCPServerData, MCPAgentGrant, MCPToolInfo } from "./hooks/use-mcp";
import { ToolMultiSelect } from "./mcp-tool-multi-select";
interface MCPGrantsDialogProps {
open: boolean;
@@ -71,12 +71,9 @@ export function MCPGrantsDialog({
}
}, [open, server.id, onLoadGrants, onLoadTools]);
// Resolve agent display name from UUID
const agentNameMap = useMemo(() => {
const map = new Map<string, string>();
for (const a of agents) {
map.set(a.id, a.display_name || a.agent_key);
}
for (const a of agents) map.set(a.id, a.display_name || a.agent_key);
return map;
}, [agents]);
@@ -99,27 +96,17 @@ export function MCPGrantsDialog({
const isEditing = editingGrantId !== null;
const handleGrant = async () => {
if (!agentId) {
setError(t("grants.agentRequired"));
return;
}
if (!agentId) { setError(t("grants.agentRequired")); return; }
const existing = grants.find((g) => g.agent_id === agentId);
setLoading(true);
setError("");
try {
const allow = toolAllow.length > 0 ? toolAllow : undefined;
const deny = toolDeny.length > 0 ? toolDeny : undefined;
await onGrant(agentId, allow, deny);
if (existing) {
setGrants((prev) =>
prev.map((g) =>
g.agent_id === agentId
? { ...g, tool_allow: allow ?? null, tool_deny: deny ?? null }
: g
)
prev.map((g) => g.agent_id === agentId ? { ...g, tool_allow: allow ?? null, tool_deny: deny ?? null } : g)
);
} else {
setGrants((prev) => [
@@ -164,7 +151,7 @@ export function MCPGrantsDialog({
</DialogHeader>
<div className="space-y-4 -mx-4 px-4 sm:-mx-6 sm:px-6 overflow-y-auto min-h-0">
{/* Existing grants */}
{/* Existing grants list */}
{grants.length > 0 && (
<div className="space-y-2">
<Label>{t("grants.currentGrants")}</Label>
@@ -195,16 +182,16 @@ export function MCPGrantsDialog({
{hasAllow && (
<div className="flex flex-wrap items-center gap-1">
<Badge variant="success" className="text-[10px] px-1.5 py-0">allow</Badge>
{grant.tool_allow!.map((t) => (
<Badge key={t} variant="secondary" className="font-mono text-[10px] px-1.5 py-0">{t}</Badge>
{grant.tool_allow!.map((tool) => (
<Badge key={tool} variant="secondary" className="font-mono text-[10px] px-1.5 py-0">{tool}</Badge>
))}
</div>
)}
{hasDeny && (
<div className="flex flex-wrap items-center gap-1">
<Badge variant="destructive" className="text-[10px] px-1.5 py-0">deny</Badge>
{grant.tool_deny!.map((t) => (
<Badge key={t} variant="secondary" className="font-mono text-[10px] px-1.5 py-0">{t}</Badge>
{grant.tool_deny!.map((tool) => (
<Badge key={tool} variant="secondary" className="font-mono text-[10px] px-1.5 py-0">{tool}</Badge>
))}
</div>
)}
@@ -288,157 +275,13 @@ export function MCPGrantsDialog({
)}
</Button>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
</div>
{/* Portal target for dropdowns — inside dialog (pointer events), outside overflow (no clipping) */}
{/* Portal target for dropdowns — inside dialog, outside overflow clipping */}
<div ref={portalRef} className="relative" />
</DialogContent>
</Dialog>
);
}
// Inline multi-select for MCP tool names with dropdown + free-text support.
function ToolMultiSelect({
value,
onChange,
options,
placeholder = "Select or type tool names...",
portalContainer,
}: {
value: string[];
onChange: (value: string[]) => void;
options: MCPToolInfo[];
placeholder?: string;
portalContainer?: React.RefObject<HTMLDivElement | null>;
}) {
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const filtered = useMemo(() => {
const q = search.toLowerCase();
return options
.filter((t) => !value.includes(t.name))
.filter((t) => !q || t.name.toLowerCase().includes(q) || (t.description ?? "").toLowerCase().includes(q));
}, [options, value, search]);
useEffect(() => {
if (!open) return;
const handler = (e: MouseEvent) => {
const target = e.target as Node;
const inContainer = containerRef.current?.contains(target);
const inPortal = portalContainer?.current?.contains(target);
if (!inContainer && !inPortal) {
setOpen(false);
}
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, [open, portalContainer]);
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]!);
}
};
// Portal dropdown: compute position relative to the portal container.
// Can't use position:fixed because Radix Dialog's transform creates a new containing block.
const [dropdownStyle, setDropdownStyle] = useState<React.CSSProperties>({});
useLayoutEffect(() => {
if (!open || filtered.length === 0 || !containerRef.current || !portalContainer?.current) return;
const inputRect = containerRef.current.getBoundingClientRect();
const portalRect = portalContainer.current.getBoundingClientRect();
setDropdownStyle({
position: "absolute",
top: inputRect.bottom - portalRect.top + 4,
left: inputRect.left - portalRect.left,
width: inputRect.width,
zIndex: 50,
});
}, [open, filtered.length, search, value, portalContainer]);
return (
<div ref={containerRef} className="relative">
<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-2",
)}
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-base md:text-sm outline-none"
/>
<ChevronDownIcon
className="text-muted-foreground size-4 shrink-0 cursor-pointer opacity-50"
onClick={() => setOpen(!open)}
/>
</div>
{open && filtered.length > 0 && portalContainer?.current && createPortal(
<div
style={dropdownStyle}
className="bg-popover text-popover-foreground max-h-56 overflow-y-auto rounded-md border p-1 shadow-md"
>
{filtered.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 flex-col items-start rounded-sm px-2 py-1.5 outline-hidden select-none"
>
<span className="truncate font-mono text-xs">{t.name}</span>
{t.description && (
<span className="text-muted-foreground truncate text-[11px] w-full text-left">
{t.description}
</span>
)}
</button>
))}
</div>,
portalContainer.current,
)}
</div>
);
}
@@ -0,0 +1,147 @@
import { useState, useEffect, useMemo, useRef, useLayoutEffect } from "react";
import { createPortal } from "react-dom";
import { X, ChevronDownIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import type { MCPToolInfo } from "./hooks/use-mcp";
interface ToolMultiSelectProps {
value: string[];
onChange: (value: string[]) => void;
options: MCPToolInfo[];
placeholder?: string;
portalContainer?: React.RefObject<HTMLDivElement | null>;
}
/** Multi-select input for MCP tool names with dropdown + free-text/keyboard support. */
export function ToolMultiSelect({
value,
onChange,
options,
placeholder = "Select or type tool names...",
portalContainer,
}: ToolMultiSelectProps) {
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const filtered = useMemo(() => {
const q = search.toLowerCase();
return options
.filter((t) => !value.includes(t.name))
.filter((t) => !q || t.name.toLowerCase().includes(q) || (t.description ?? "").toLowerCase().includes(q));
}, [options, value, search]);
useEffect(() => {
if (!open) return;
const handler = (e: MouseEvent) => {
const target = e.target as Node;
const inContainer = containerRef.current?.contains(target);
const inPortal = portalContainer?.current?.contains(target);
if (!inContainer && !inPortal) setOpen(false);
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, [open, portalContainer]);
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]!);
}
};
// Portal dropdown: position relative to portal container (fixed avoids Radix Dialog transform).
const [dropdownStyle, setDropdownStyle] = useState<React.CSSProperties>({});
useLayoutEffect(() => {
if (!open || filtered.length === 0 || !containerRef.current || !portalContainer?.current) return;
const inputRect = containerRef.current.getBoundingClientRect();
const portalRect = portalContainer.current.getBoundingClientRect();
setDropdownStyle({
position: "absolute",
top: inputRect.bottom - portalRect.top + 4,
left: inputRect.left - portalRect.left,
width: inputRect.width,
zIndex: 50,
});
}, [open, filtered.length, search, value, portalContainer]);
return (
<div ref={containerRef} className="relative">
<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-2",
)}
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-base md:text-sm outline-none"
/>
<ChevronDownIcon
className="text-muted-foreground size-4 shrink-0 cursor-pointer opacity-50"
onClick={() => setOpen(!open)}
/>
</div>
{open && filtered.length > 0 && portalContainer?.current && createPortal(
<div
style={dropdownStyle}
className="bg-popover text-popover-foreground max-h-56 overflow-y-auto rounded-md border p-1 shadow-md pointer-events-auto"
>
{filtered.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 flex-col items-start rounded-sm px-2 py-1.5 outline-hidden select-none"
>
<span className="truncate font-mono text-xs">{t.name}</span>
{t.description && (
<span className="text-muted-foreground truncate text-[11px] w-full text-left">
{t.description}
</span>
)}
</button>
))}
</div>,
portalContainer.current,
)}
</div>
);
}
@@ -1,7 +1,7 @@
import { useState, useCallback } from "react";
import { useHttp } from "@/hooks/use-ws";
interface VerifyResult {
export interface VerifyResult {
valid: boolean;
error?: string;
dimensions?: number;
@@ -0,0 +1,124 @@
import { useTranslation } from "react-i18next";
import { Loader2, CheckCircle2, XCircle, AlertTriangle } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Button } from "@/components/ui/button";
import type { VerifyResult } from "../hooks/use-provider-verify";
interface ProviderEmbeddingSectionProps {
embEnabled: boolean;
setEmbEnabled: (v: boolean) => void;
embModel: string;
setEmbModel: (v: string) => void;
embApiBase: string;
setEmbApiBase: (v: string) => void;
onVerify: () => void;
verifying: boolean;
verifyResult: VerifyResult | null;
}
export function ProviderEmbeddingSection({
embEnabled,
setEmbEnabled,
embModel,
setEmbModel,
embApiBase,
setEmbApiBase,
onVerify,
verifying,
verifyResult,
}: ProviderEmbeddingSectionProps) {
const { t } = useTranslation("providers");
return (
<section className="space-y-3 rounded-lg border p-3 sm:p-4 overflow-hidden">
<h3 className="text-sm font-medium">{t("detail.embeddingSection")}</h3>
<div className="flex items-center justify-between gap-4">
<div className="space-y-0.5">
<Label htmlFor="embEnabled" className="text-sm font-medium">
{t("embedding.enable")}
</Label>
<p className="text-xs text-muted-foreground">{t("embedding.enableDesc")}</p>
</div>
<Switch id="embEnabled" checked={embEnabled} onCheckedChange={setEmbEnabled} />
</div>
{embEnabled ? (
<div className="space-y-3 pt-1">
<div className="space-y-2">
<Label htmlFor="embModel">{t("embedding.model")}</Label>
<Input
id="embModel"
value={embModel}
onChange={(e) => setEmbModel(e.target.value)}
placeholder="text-embedding-3-small"
className="text-base md:text-sm"
/>
</div>
<div className="space-y-2">
<Label>{t("embedding.dimensions")}</Label>
<p className="text-sm text-muted-foreground">1536</p>
<p className="text-xs text-muted-foreground">{t("embedding.dimensionsHint")}</p>
</div>
<div className="space-y-2">
<Label htmlFor="embApiBase">{t("embedding.apiBase")}</Label>
<Input
id="embApiBase"
value={embApiBase}
onChange={(e) => setEmbApiBase(e.target.value)}
placeholder={t("embedding.apiBasePlaceholder")}
className="text-base md:text-sm"
/>
<p className="text-xs text-muted-foreground">{t("embedding.apiBaseHint")}</p>
</div>
<div className="flex items-center gap-3">
<Button
type="button"
variant="outline"
size="sm"
disabled={verifying}
onClick={onVerify}
>
{verifying ? <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" /> : null}
{t("embedding.verify")}
</Button>
{verifyResult ? (
<span
className={`flex items-center gap-1 text-xs ${
verifyResult.valid
? verifyResult.dimension_mismatch
? "text-amber-600 dark:text-amber-400"
: "text-success"
: "text-destructive"
}`}
>
{verifyResult.valid ? (
<>
{verifyResult.dimension_mismatch ? (
<AlertTriangle className="h-3.5 w-3.5" />
) : (
<CheckCircle2 className="h-3.5 w-3.5" />
)}
{verifyResult.dimension_mismatch
? t("embedding.dimensionsMismatch", { count: verifyResult.dimensions })
: `${verifyResult.dimensions} dimensions`}
</>
) : (
<>
<XCircle className="h-3.5 w-3.5" />
{verifyResult.error || t("embedding.verifyFailed")}
</>
)}
</span>
) : null}
</div>
</div>
) : null}
</section>
);
}
@@ -0,0 +1,90 @@
import type { ChatGPTOAuthAvailability } from "../hooks/use-chatgpt-oauth-provider-statuses";
import type { ChatGPTOAuthRoutingConfig } from "@/types/agent";
import { normalizeReasoningEffort, normalizeReasoningFallback } from "@/types/provider";
// Provider types that don't use API keys
export const NO_API_KEY_TYPES = new Set(["claude_cli", "acp", "chatgpt_oauth"]);
// Provider types that don't support embedding
export const NO_EMBEDDING_TYPES = new Set([
"claude_cli",
"acp",
"chatgpt_oauth",
"suno",
"anthropic_native",
]);
export const SIMPLE_REASONING_LEVELS = new Set(["off", "low", "medium", "high"]);
export const ADVANCED_REASONING_LEVELS = ["off", "auto", "none", "minimal", "low", "medium", "high", "xhigh"] as const;
export const REASONING_FALLBACKS = ["downgrade", "provider_default", "off"] as const;
/** Determine provider OAuth availability from status map */
export function providerStatus(
providerName: string,
statusByName: Map<string, { availability: ChatGPTOAuthAvailability }>,
enabled?: boolean,
): ChatGPTOAuthAvailability {
return (
statusByName.get(providerName)?.availability ??
(enabled === false ? "disabled" : "needs_sign_in")
);
}
/** Serialize routing config for dirty-check comparison */
export function routingSignature(routing: ChatGPTOAuthRoutingConfig): string {
const extras = Array.from(
new Set((routing.extra_provider_names ?? []).map((name) => name.trim()).filter(Boolean)),
);
const strategy =
routing.strategy === "round_robin" || routing.strategy === "priority_order"
? routing.strategy
: "primary_first";
return JSON.stringify({ strategy, extra_provider_names: extras });
}
/** Serialize reasoning config for dirty-check comparison */
export function reasoningSignature(effort: string, fallback: string): string {
return JSON.stringify({
effort: normalizeReasoningEffort(effort) || "off",
fallback: normalizeReasoningFallback(fallback),
});
}
/** Return non-empty API key value for comparison (handles masked "***") */
export function comparableAPIKeyValue(
apiKey: string,
savedAPIKey: string,
showApiKey: boolean,
): string {
if (!showApiKey) return "";
if (apiKey === "***") return "";
if (apiKey === "" && savedAPIKey === "***") return "";
return apiKey;
}
/** Build a serializable form signature for dirty-check comparison */
export function providerFormSignature(input: {
displayName: string;
apiKey: string;
savedAPIKey: string;
showApiKey: boolean;
enabled: boolean;
embEnabled: boolean;
embModel: string;
embApiBase: string;
routing: ChatGPTOAuthRoutingConfig;
reasoningEffort: string;
reasoningFallback: string;
isOAuth: boolean;
}): string {
return JSON.stringify({
displayName: input.displayName,
apiKey: comparableAPIKeyValue(input.apiKey, input.savedAPIKey, input.showApiKey),
enabled: input.enabled,
embEnabled: input.embEnabled,
embModel: input.embModel,
embApiBase: input.embApiBase,
routing: input.isOAuth ? routingSignature(input.routing) : "",
reasoning: reasoningSignature(input.reasoningEffort, input.reasoningFallback),
});
}
@@ -1,22 +1,9 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
Copy,
Loader2,
CheckCircle2,
XCircle,
AlertTriangle,
} from "lucide-react";
import { Copy } from "lucide-react";
import { StickySaveBar } from "@/components/shared/sticky-save-bar";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -26,125 +13,45 @@ import { useProviders } from "../hooks/use-providers";
import { useProviderModels } from "../hooks/use-provider-models";
import { useProviderVerify } from "../hooks/use-provider-verify";
import { ProviderOAuthAccountSection } from "./provider-oauth-account-section";
import { ProviderReasoningSection } from "./provider-reasoning-section";
import { ProviderEmbeddingSection } from "./provider-embedding-section";
import { ProviderPoolActivitySection } from "./provider-pool-activity-section";
import {
buildProviderSettingsWithChatGPTOAuthRouting,
buildProviderSettingsWithReasoningDefaults,
getChatGPTOAuthProviderRouting,
getEmbeddingSettings,
getProviderReasoningDefaults,
normalizeReasoningEffort,
normalizeReasoningFallback,
deriveLegacyThinkingLevel,
} from "@/types/provider";
import type { ProviderData, ProviderInput } from "@/types/provider";
import type { ChatGPTOAuthRoutingConfig } from "@/types/agent";
import {
useChatGPTOAuthProviderStatuses,
type ChatGPTOAuthAvailability,
} from "../hooks/use-chatgpt-oauth-provider-statuses";
import { useChatGPTOAuthProviderStatuses } from "../hooks/use-chatgpt-oauth-provider-statuses";
import { useChatGPTOAuthProviderQuotas } from "../hooks/use-chatgpt-oauth-provider-quotas";
import {
ChatGPTOAuthRoutingSection,
} from "@/pages/agents/agent-detail/config-sections";
import { ChatGPTOAuthRoutingSection } from "@/pages/agents/agent-detail/config-sections";
import type { CodexPoolEntry } from "@/pages/agents/agent-detail/codex-pool-activity-panel";
import { useProviderCodexPoolActivity } from "../hooks/use-provider-codex-pool-activity";
import { ProviderPoolActivitySection } from "./provider-pool-activity-section";
import {
NO_API_KEY_TYPES,
NO_EMBEDDING_TYPES,
SIMPLE_REASONING_LEVELS,
providerStatus,
routingSignature,
comparableAPIKeyValue,
providerFormSignature,
reasoningSignature,
} from "./provider-overview-helpers";
interface ProviderOverviewProps {
provider: ProviderData;
onUpdate: (id: string, data: ProviderInput) => Promise<void>;
}
const NO_API_KEY_TYPES = new Set(["claude_cli", "acp", "chatgpt_oauth"]);
const NO_EMBEDDING_TYPES = new Set([
"claude_cli",
"acp",
"chatgpt_oauth",
"suno",
"anthropic_native",
]);
const SIMPLE_REASONING_LEVELS = new Set(["off", "low", "medium", "high"]);
const ADVANCED_REASONING_LEVELS = ["off", "auto", "none", "minimal", "low", "medium", "high", "xhigh"] as const;
const REASONING_FALLBACKS = ["downgrade", "provider_default", "off"] as const;
function providerStatus(
providerName: string,
statusByName: Map<string, { availability: ChatGPTOAuthAvailability }>,
enabled?: boolean,
): ChatGPTOAuthAvailability {
return (
statusByName.get(providerName)?.availability ??
(enabled === false ? "disabled" : "needs_sign_in")
);
}
function routingSignature(routing: ChatGPTOAuthRoutingConfig): string {
const extras = Array.from(
new Set((routing.extra_provider_names ?? []).map((name) => name.trim()).filter(Boolean)),
);
const strategy =
routing.strategy === "round_robin" || routing.strategy === "priority_order"
? routing.strategy
: "primary_first";
return JSON.stringify({
strategy,
extra_provider_names: extras,
});
}
function comparableAPIKeyValue(
apiKey: string,
savedAPIKey: string,
showApiKey: boolean,
): string {
if (!showApiKey) return "";
if (apiKey === "***") return "";
if (apiKey === "" && savedAPIKey === "***") return "";
return apiKey;
}
function providerFormSignature(input: {
displayName: string;
apiKey: string;
savedAPIKey: string;
showApiKey: boolean;
enabled: boolean;
embEnabled: boolean;
embModel: string;
embApiBase: string;
routing: ChatGPTOAuthRoutingConfig;
reasoningEffort: string;
reasoningFallback: string;
isOAuth: boolean;
}): string {
return JSON.stringify({
displayName: input.displayName,
apiKey: comparableAPIKeyValue(input.apiKey, input.savedAPIKey, input.showApiKey),
enabled: input.enabled,
embEnabled: input.embEnabled,
embModel: input.embModel,
embApiBase: input.embApiBase,
routing: input.isOAuth ? routingSignature(input.routing) : "",
reasoning: reasoningSignature(input.reasoningEffort, input.reasoningFallback),
});
}
function reasoningSignature(effort: string, fallback: string): string {
return JSON.stringify({
effort: normalizeReasoningEffort(effort) || "off",
fallback: normalizeReasoningFallback(fallback),
});
}
export function ProviderOverview({ provider, onUpdate }: ProviderOverviewProps) {
const { t } = useTranslation("providers");
const { t: tc } = useTranslation("common");
const { providers } = useProviders();
const {
models: providerModels,
reasoningDefaults: providerReasoningDefaults,
} = useProviderModels(provider.id);
const { models: providerModels, reasoningDefaults: providerReasoningDefaults } = useProviderModels(provider.id);
const { statuses } = useChatGPTOAuthProviderStatuses(providers);
const typeInfo = PROVIDER_TYPES.find((pt) => pt.value === provider.provider_type);
@@ -153,10 +60,8 @@ export function ProviderOverview({ provider, onUpdate }: ProviderOverviewProps)
const showEmbedding = !NO_EMBEDDING_TYPES.has(provider.provider_type);
const isOAuth = provider.provider_type === "chatgpt_oauth";
const providerByName = useMemo(
() => new Map(providers.map((item) => [item.name, item])),
[providers],
);
// --- Pool ownership & status maps ---
const providerByName = useMemo(() => new Map(providers.map((item) => [item.name, item])), [providers]);
const poolOwnership = useMemo(() => {
const membersByOwner = new Map<string, string[]>();
const ownerByMember = new Map<string, string>();
@@ -166,37 +71,21 @@ export function ProviderOverview({ provider, onUpdate }: ProviderOverviewProps)
if (!routing || routing.extraProviderNames.length === 0) continue;
membersByOwner.set(item.name, routing.extraProviderNames);
for (const memberName of routing.extraProviderNames) {
if (!ownerByMember.has(memberName)) {
ownerByMember.set(memberName, item.name);
}
if (!ownerByMember.has(memberName)) ownerByMember.set(memberName, item.name);
}
}
return { membersByOwner, ownerByMember };
}, [providers]);
const statusByName = useMemo(
() => new Map(statuses.map((status) => [status.provider.name, status])),
[statuses],
);
const managedByOwnerName = isOAuth
? poolOwnership.ownerByMember.get(provider.name)
: undefined;
const managedByProvider = managedByOwnerName
? providerByName.get(managedByOwnerName)
: undefined;
const managedMemberCount = isOAuth
? poolOwnership.membersByOwner.get(provider.name)?.length ?? 0
: 0;
const statusByName = useMemo(() => new Map(statuses.map((s) => [s.provider.name, s])), [statuses]);
const managedByOwnerName = isOAuth ? poolOwnership.ownerByMember.get(provider.name) : undefined;
const managedByProvider = managedByOwnerName ? providerByName.get(managedByOwnerName) : undefined;
const managedMemberCount = isOAuth ? poolOwnership.membersByOwner.get(provider.name)?.length ?? 0 : 0;
const canEditPoolRouting = isOAuth && !managedByOwnerName;
const currentOAuthAvailability = providerStatus(
provider.name,
statusByName,
provider.enabled,
);
const currentOAuthAvailability = providerStatus(provider.name, statusByName, provider.enabled);
// --- Form state ---
const initialRouting = getChatGPTOAuthProviderRouting(provider.settings);
const initialReasoningDefaults = getProviderReasoningDefaults(provider.settings)
?? providerReasoningDefaults
?? null;
const initialReasoningDefaults = getProviderReasoningDefaults(provider.settings) ?? providerReasoningDefaults ?? null;
const initialReasoningEffort = initialReasoningDefaults?.effort ?? "off";
const initialReasoningFallback = initialReasoningDefaults?.fallback ?? "downgrade";
@@ -207,540 +96,166 @@ export function ProviderOverview({ provider, onUpdate }: ProviderOverviewProps)
strategy: initialRouting?.strategy ?? "primary_first",
extra_provider_names: initialRouting?.extraProviderNames ?? [],
});
const selectedPoolProviderNames = useMemo(
() =>
canEditPoolRouting
? Array.from(
new Set(
[provider.name, ...(poolRouting.extra_provider_names ?? [])]
.filter(Boolean)
.filter((name) => name === provider.name || providerByName.has(name)),
),
)
: [provider.name],
[canEditPoolRouting, poolRouting.extra_provider_names, provider.name, providerByName],
);
const initEmb = getEmbeddingSettings(provider.settings);
const [embEnabled, setEmbEnabled] = useState(initEmb?.enabled ?? false);
const [embModel, setEmbModel] = useState(initEmb?.model ?? "");
const [embApiBase, setEmbApiBase] = useState(initEmb?.api_base ?? "");
const [reasoningThinkingLevel, setReasoningThinkingLevel] = useState(
deriveLegacyThinkingLevel(initialReasoningEffort),
);
const [reasoningThinkingLevel, setReasoningThinkingLevel] = useState(deriveLegacyThinkingLevel(initialReasoningEffort));
const [reasoningEffort, setReasoningEffort] = useState(initialReasoningEffort);
const [reasoningFallback, setReasoningFallback] = useState(initialReasoningFallback);
const [reasoningExpert, setReasoningExpert] = useState(
Boolean(initialReasoningDefaults) && (
!SIMPLE_REASONING_LEVELS.has(initialReasoningEffort)
|| initialReasoningFallback !== "downgrade"
),
Boolean(initialReasoningDefaults) && (!SIMPLE_REASONING_LEVELS.has(initialReasoningEffort) || initialReasoningFallback !== "downgrade"),
);
const [reasoningPreviewModel, setReasoningPreviewModel] = useState("");
const syncedProviderIDRef = useRef(provider.id);
const reasoningCapableModels = useMemo(
() => providerModels.filter((model) => (model.reasoning?.levels?.length ?? 0) > 0),
[providerModels],
);
// --- Reasoning model preview ---
const reasoningCapableModels = useMemo(() => providerModels.filter((m) => (m.reasoning?.levels?.length ?? 0) > 0), [providerModels]);
const reasoningPreviewEntry = useMemo(
() => reasoningCapableModels.find((model) => model.id === reasoningPreviewModel)
?? reasoningCapableModels[0]
?? null,
() => reasoningCapableModels.find((m) => m.id === reasoningPreviewModel) ?? reasoningCapableModels[0] ?? null,
[reasoningCapableModels, reasoningPreviewModel],
);
const reasoningPreviewCapability = reasoningPreviewEntry?.reasoning ?? null;
const showReasoningDefaults = Boolean(initialReasoningDefaults) || reasoningCapableModels.length > 0;
const savedReasoningSignature = useMemo(
() => reasoningSignature(
initialReasoningDefaults?.effort ?? "off",
initialReasoningDefaults?.fallback ?? "downgrade",
),
[initialReasoningDefaults?.effort, initialReasoningDefaults?.fallback],
);
const draftReasoningSignature = useMemo(
() => reasoningSignature(
reasoningExpert ? reasoningEffort : reasoningThinkingLevel,
reasoningExpert ? reasoningFallback : "downgrade",
),
[reasoningEffort, reasoningExpert, reasoningFallback, reasoningThinkingLevel],
);
const savedFormSignature = useMemo(
() =>
providerFormSignature({
displayName: provider.display_name || "",
apiKey: provider.api_key || "",
savedAPIKey: provider.api_key || "",
showApiKey,
enabled: provider.enabled,
embEnabled: initEmb?.enabled ?? false,
embModel: initEmb?.model ?? "",
embApiBase: initEmb?.api_base ?? "",
routing: {
strategy: initialRouting?.strategy ?? "primary_first",
extra_provider_names: initialRouting?.extraProviderNames ?? [],
},
reasoningEffort: initialReasoningDefaults?.effort ?? "off",
reasoningFallback: initialReasoningDefaults?.fallback ?? "downgrade",
isOAuth,
}),
// --- Dirty checking ---
const savedReasoningSig = useMemo(() => reasoningSignature(initialReasoningDefaults?.effort ?? "off", initialReasoningDefaults?.fallback ?? "downgrade"), [initialReasoningDefaults?.effort, initialReasoningDefaults?.fallback]);
const draftReasoningSig = useMemo(() => reasoningSignature(reasoningExpert ? reasoningEffort : reasoningThinkingLevel, reasoningExpert ? reasoningFallback : "downgrade"), [reasoningEffort, reasoningExpert, reasoningFallback, reasoningThinkingLevel]);
const savedFormSig = useMemo(
() => providerFormSignature({ displayName: provider.display_name || "", apiKey: provider.api_key || "", savedAPIKey: provider.api_key || "", showApiKey, enabled: provider.enabled, embEnabled: initEmb?.enabled ?? false, embModel: initEmb?.model ?? "", embApiBase: initEmb?.api_base ?? "", routing: { strategy: initialRouting?.strategy ?? "primary_first", extra_provider_names: initialRouting?.extraProviderNames ?? [] }, reasoningEffort: initialReasoningDefaults?.effort ?? "off", reasoningFallback: initialReasoningDefaults?.fallback ?? "downgrade", isOAuth }),
[initEmb?.api_base, initEmb?.enabled, initEmb?.model, initialReasoningDefaults?.effort, initialReasoningDefaults?.fallback, initialRouting?.extraProviderNames, initialRouting?.strategy, isOAuth, provider.api_key, provider.display_name, provider.enabled, showApiKey],
);
const savedFormSignatureRef = useRef(savedFormSignature);
const draftFormSignature = useMemo(
() =>
providerFormSignature({
displayName,
apiKey,
savedAPIKey: provider.api_key || "",
showApiKey,
enabled,
embEnabled,
embModel,
embApiBase,
routing: poolRouting,
reasoningEffort: reasoningExpert ? reasoningEffort : reasoningThinkingLevel,
reasoningFallback: reasoningExpert ? reasoningFallback : "downgrade",
isOAuth,
}),
const savedFormSigRef = useRef(savedFormSig);
const draftFormSig = useMemo(
() => providerFormSignature({ displayName, apiKey, savedAPIKey: provider.api_key || "", showApiKey, enabled, embEnabled, embModel, embApiBase, routing: poolRouting, reasoningEffort: reasoningExpert ? reasoningEffort : reasoningThinkingLevel, reasoningFallback: reasoningExpert ? reasoningFallback : "downgrade", isOAuth }),
[apiKey, displayName, embApiBase, embEnabled, embModel, enabled, isOAuth, poolRouting, provider.api_key, reasoningEffort, reasoningExpert, reasoningFallback, reasoningThinkingLevel, showApiKey],
);
// --- Sync from provider on external update ---
useEffect(() => {
const nextProviderID = provider.id;
const nextID = provider.id;
const es = getEmbeddingSettings(provider.settings);
const routing = getChatGPTOAuthProviderRouting(provider.settings);
const reasoning = getProviderReasoningDefaults(provider.settings) ?? providerReasoningDefaults;
const syncFromProvider = () => {
setEmbEnabled(es?.enabled ?? false);
setEmbModel(es?.model ?? "");
setEmbApiBase(es?.api_base ?? "");
setPoolRouting({
strategy: routing?.strategy ?? "primary_first",
extra_provider_names: routing?.extraProviderNames ?? [],
});
const nextReasoningEffort = reasoning?.effort ?? "off";
const nextReasoningFallback = reasoning?.fallback ?? "downgrade";
setReasoningEffort(nextReasoningEffort);
setReasoningFallback(nextReasoningFallback);
setReasoningThinkingLevel(deriveLegacyThinkingLevel(nextReasoningEffort));
setReasoningExpert(
!SIMPLE_REASONING_LEVELS.has(nextReasoningEffort)
|| nextReasoningFallback !== "downgrade",
);
setDisplayName(provider.display_name || "");
setApiKey(provider.api_key || "");
setEnabled(provider.enabled);
setEmbEnabled(es?.enabled ?? false); setEmbModel(es?.model ?? ""); setEmbApiBase(es?.api_base ?? "");
setPoolRouting({ strategy: routing?.strategy ?? "primary_first", extra_provider_names: routing?.extraProviderNames ?? [] });
const nextEffort = reasoning?.effort ?? "off"; const nextFallback = reasoning?.fallback ?? "downgrade";
setReasoningEffort(nextEffort); setReasoningFallback(nextFallback);
setReasoningThinkingLevel(deriveLegacyThinkingLevel(nextEffort));
setReasoningExpert(!SIMPLE_REASONING_LEVELS.has(nextEffort) || nextFallback !== "downgrade");
setDisplayName(provider.display_name || ""); setApiKey(provider.api_key || ""); setEnabled(provider.enabled);
};
if (nextProviderID !== syncedProviderIDRef.current) {
syncedProviderIDRef.current = nextProviderID;
savedFormSignatureRef.current = savedFormSignature;
syncFromProvider();
return;
}
const previousSavedSignature = savedFormSignatureRef.current;
if (savedFormSignature === previousSavedSignature) {
return;
}
if (draftFormSignature === previousSavedSignature) {
syncFromProvider();
}
savedFormSignatureRef.current = savedFormSignature;
}, [draftFormSignature, provider.api_key, provider.display_name, provider.enabled, provider.id, provider.settings, providerReasoningDefaults, savedFormSignature]);
if (nextID !== syncedProviderIDRef.current) { syncedProviderIDRef.current = nextID; savedFormSigRef.current = savedFormSig; syncFromProvider(); return; }
const prev = savedFormSigRef.current;
if (savedFormSig === prev) return;
if (draftFormSig === prev) syncFromProvider();
savedFormSigRef.current = savedFormSig;
}, [draftFormSig, provider.api_key, provider.display_name, provider.enabled, provider.id, provider.settings, providerReasoningDefaults, savedFormSig]);
useEffect(() => {
if (reasoningCapableModels.length === 0) {
if (reasoningPreviewModel !== "") setReasoningPreviewModel("");
return;
}
if (reasoningCapableModels.some((model) => model.id === reasoningPreviewModel)) {
return;
}
if (reasoningCapableModels.length === 0) { if (reasoningPreviewModel !== "") setReasoningPreviewModel(""); return; }
if (reasoningCapableModels.some((m) => m.id === reasoningPreviewModel)) return;
setReasoningPreviewModel(reasoningCapableModels[0]?.id ?? "");
}, [reasoningCapableModels, reasoningPreviewModel]);
const quotaProviderNames = useMemo(
() => {
if (!isOAuth) return [];
return Array.from(
new Set(
selectedPoolProviderNames.filter((providerName) => {
if (!providerName) return false;
const item = providerByName.get(providerName);
return (
providerStatus(providerName, statusByName, item?.enabled) === "ready"
);
}),
),
);
},
[
isOAuth,
providerByName,
selectedPoolProviderNames,
statusByName,
],
// --- Pool entries & quota ---
const selectedPoolProviderNames = useMemo(
() => canEditPoolRouting ? Array.from(new Set([provider.name, ...(poolRouting.extra_provider_names ?? [])].filter(Boolean).filter((n) => n === provider.name || providerByName.has(n)))) : [provider.name],
[canEditPoolRouting, poolRouting.extra_provider_names, provider.name, providerByName],
);
const {
quotaByName,
isLoading: quotasLoading,
isFetching: quotasFetching,
} = useChatGPTOAuthProviderQuotas(quotaProviderNames, isOAuth);
const quotaProviderNames = useMemo(() => {
if (!isOAuth) return [];
return Array.from(new Set(selectedPoolProviderNames.filter((n) => { if (!n) return false; const item = providerByName.get(n); return providerStatus(n, statusByName, item?.enabled) === "ready"; })));
}, [isOAuth, providerByName, selectedPoolProviderNames, statusByName]);
const { quotaByName, isLoading: quotasLoading, isFetching: quotasFetching } = useChatGPTOAuthProviderQuotas(quotaProviderNames, isOAuth);
const poolEntries = useMemo<CodexPoolEntry[]>(() => {
if (!canEditPoolRouting) return [];
return selectedPoolProviderNames.map((providerName) => {
const item = providerByName.get(providerName);
return {
name: providerName,
label: item?.display_name || providerName,
availability: providerStatus(providerName, statusByName, item?.enabled),
role: providerName === provider.name ? "preferred" : "extra",
requestCount: 0,
directSelectionCount: 0,
failoverServeCount: 0,
successCount: 0,
failureCount: 0,
consecutiveFailures: 0,
successRate: 0,
healthScore: 0,
healthState: "idle",
providerHref: item?.id ? `/providers/${item.id}` : undefined,
quota: quotaByName.get(providerName),
};
return selectedPoolProviderNames.map((n) => {
const item = providerByName.get(n);
return { name: n, label: item?.display_name || n, availability: providerStatus(n, statusByName, item?.enabled), role: n === provider.name ? "preferred" : "extra", requestCount: 0, directSelectionCount: 0, failoverServeCount: 0, successCount: 0, failureCount: 0, consecutiveFailures: 0, successRate: 0, healthScore: 0, healthState: "idle" as const, providerHref: item?.id ? `/providers/${item.id}` : undefined, quota: quotaByName.get(n) };
});
}, [canEditPoolRouting, provider.name, providerByName, quotaByName, selectedPoolProviderNames, statusByName]);
// Provider-scoped pool activity (only for pool owners)
const isPoolOwner = canEditPoolRouting && selectedPoolProviderNames.length > 1;
const {
data: poolActivity,
isFetching: poolActivityFetching,
refetch: refreshPoolActivity,
} = useProviderCodexPoolActivity(provider.id, 8, isPoolOwner);
const { data: poolActivity, isFetching: poolActivityFetching, refetch: refreshPoolActivity } = useProviderCodexPoolActivity(provider.id, 8, isPoolOwner);
// --- Embedding verify ---
const { verifyEmbedding, embVerifying, embResult, resetEmb } = useProviderVerify();
useEffect(() => { resetEmb(); }, [embModel, resetEmb]);
// --- Save handler ---
const [saving, setSaving] = useState(false);
const handleSave = async () => {
setSaving(true);
try {
const nextDisplayName = displayName.trim();
const nextEmbModel = embModel.trim();
const nextEmbAPIBase = embApiBase.trim();
const submittedAPIKey = showApiKey && apiKey && apiKey !== "***" ? apiKey : "";
const data: ProviderInput = {
name: provider.name,
display_name: nextDisplayName || undefined,
provider_type: provider.provider_type,
enabled,
};
if (submittedAPIKey) {
data.api_key = submittedAPIKey;
}
const data: ProviderInput = { name: provider.name, display_name: nextDisplayName || undefined, provider_type: provider.provider_type, enabled };
if (submittedAPIKey) data.api_key = submittedAPIKey;
let nextSettings = { ...((provider.settings || {}) as Record<string, unknown>) };
if (showEmbedding) {
nextSettings = {
...nextSettings,
embedding: embEnabled
? {
enabled: true,
model: nextEmbModel || undefined,
api_base: nextEmbAPIBase || undefined,
}
: { enabled: false },
};
nextSettings = { ...nextSettings, embedding: embEnabled ? { enabled: true, model: embModel.trim() || undefined, api_base: embApiBase.trim() || undefined } : { enabled: false } };
}
if (isOAuth) {
nextSettings = buildProviderSettingsWithChatGPTOAuthRouting(
nextSettings,
poolRouting,
);
}
nextSettings = buildProviderSettingsWithReasoningDefaults(
nextSettings,
showReasoningDefaults
? {
effort: reasoningExpert ? reasoningEffort : reasoningThinkingLevel,
fallback: reasoningExpert ? reasoningFallback : "downgrade",
}
: null,
);
if (isOAuth) nextSettings = buildProviderSettingsWithChatGPTOAuthRouting(nextSettings, poolRouting);
nextSettings = buildProviderSettingsWithReasoningDefaults(nextSettings, showReasoningDefaults ? { effort: reasoningExpert ? reasoningEffort : reasoningThinkingLevel, fallback: reasoningExpert ? reasoningFallback : "downgrade" } : null);
data.settings = nextSettings;
await onUpdate(provider.id, data);
setDisplayName(nextDisplayName);
setEmbModel(nextEmbModel);
setEmbApiBase(nextEmbAPIBase);
if (submittedAPIKey) {
setApiKey("***");
}
} catch {
// toast shown by hook
} finally {
setSaving(false);
}
setDisplayName(nextDisplayName); setEmbModel(embModel.trim()); setEmbApiBase(embApiBase.trim());
if (submittedAPIKey) setApiKey("***");
} catch { /* toast shown by hook */ } finally { setSaving(false); }
};
const handleCopyName = () => {
navigator.clipboard.writeText(provider.name).catch(() => {});
toast.success(tc("copy"));
};
const handleVerifyEmbedding = () => {
verifyEmbedding(provider.id, embModel.trim() || undefined, undefined);
};
const displayNameDirty = displayName !== (provider.display_name || "");
const enabledDirty = enabled !== provider.enabled;
const apiKeyDirty =
showApiKey &&
comparableAPIKeyValue(apiKey, provider.api_key || "", showApiKey) !== "";
const embeddingDirty =
embEnabled !== (initEmb?.enabled ?? false)
|| embModel !== (initEmb?.model ?? "")
|| embApiBase !== (initEmb?.api_base ?? "");
const routingDirty = isOAuth && routingSignature(poolRouting) !== routingSignature({
strategy: initialRouting?.strategy ?? "primary_first",
extra_provider_names: initialRouting?.extraProviderNames ?? [],
});
const reasoningDirty = draftReasoningSignature !== savedReasoningSignature;
const isDirty = displayNameDirty || enabledDirty || apiKeyDirty || embeddingDirty || routingDirty || reasoningDirty;
const isDirty = displayName !== (provider.display_name || "")
|| enabled !== provider.enabled
|| (showApiKey && comparableAPIKeyValue(apiKey, provider.api_key || "", showApiKey) !== "")
|| embEnabled !== (initEmb?.enabled ?? false) || embModel !== (initEmb?.model ?? "") || embApiBase !== (initEmb?.api_base ?? "")
|| (isOAuth && routingSignature(poolRouting) !== routingSignature({ strategy: initialRouting?.strategy ?? "primary_first", extra_provider_names: initialRouting?.extraProviderNames ?? [] }))
|| draftReasoningSig !== savedReasoningSig;
return (
<div className="space-y-4">
{/* Identity section */}
<section className="space-y-4 rounded-lg border p-3 sm:p-4 overflow-hidden">
<h3 className="text-sm font-medium">{t("detail.identity")}</h3>
<div className="space-y-2">
<Label htmlFor="displayName">{t("form.displayName")}</Label>
<Input
id="displayName"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder={isOAuth ? t("form.oauthDisplayNamePlaceholder") : t("form.displayNamePlaceholder")}
className="text-base md:text-sm"
/>
{isOAuth ? (
<p className="text-xs text-muted-foreground">{t("form.oauthDisplayNameHint")}</p>
) : null}
<Input id="displayName" value={displayName} onChange={(e) => setDisplayName(e.target.value)} placeholder={isOAuth ? t("form.oauthDisplayNamePlaceholder") : t("form.displayNamePlaceholder")} className="text-base md:text-sm" />
{isOAuth ? <p className="text-xs text-muted-foreground">{t("form.oauthDisplayNameHint")}</p> : null}
</div>
<div className="space-y-2">
<Label>{t("detail.providerType")}</Label>
<div className="flex items-center gap-2">
<Badge variant="outline">{typeLabel}</Badge>
</div>
<div className="flex items-center gap-2"><Badge variant="outline">{typeLabel}</Badge></div>
</div>
<div className="space-y-2">
<Label>{isOAuth ? t("form.oauthAlias") : t("form.name")}</Label>
<div className="flex items-center gap-2">
<code className="flex-1 rounded-md border bg-muted px-3 py-2 font-mono text-sm text-muted-foreground">
{provider.name}
</code>
<Button type="button" variant="outline" size="icon" className="size-9 shrink-0" onClick={handleCopyName}>
<code className="flex-1 rounded-md border bg-muted px-3 py-2 font-mono text-sm text-muted-foreground">{provider.name}</code>
<Button type="button" variant="outline" size="icon" className="size-9 shrink-0" onClick={() => { navigator.clipboard.writeText(provider.name).catch(() => {}); toast.success(tc("copy")); }}>
<Copy className="h-4 w-4" />
</Button>
</div>
</div>
</section>
{isOAuth ? (
<ProviderOAuthAccountSection
provider={provider}
managedByProvider={managedByProvider}
managedMemberCount={managedMemberCount}
availability={currentOAuthAvailability}
quota={quotaByName.get(provider.name)}
quotaLoading={quotasLoading || quotasFetching}
/>
) : null}
{isOAuth ? <ProviderOAuthAccountSection provider={provider} managedByProvider={managedByProvider} managedMemberCount={managedMemberCount} availability={currentOAuthAvailability} quota={quotaByName.get(provider.name)} quotaLoading={quotasLoading || quotasFetching} /> : null}
{showReasoningDefaults ? (
<section className="space-y-4 rounded-lg border p-3 sm:p-4 overflow-hidden">
<div className="space-y-1">
<h3 className="text-sm font-medium">{t("detail.reasoningDefaultsTitle")}</h3>
<p className="text-xs text-muted-foreground">
{t("detail.reasoningDefaultsDescription")}
</p>
</div>
<div className="space-y-2">
<Label>{t("detail.reasoningPreset")}</Label>
<Select
value={reasoningThinkingLevel}
onValueChange={(value) => {
setReasoningThinkingLevel(value);
setReasoningEffort(value);
}}
>
<SelectTrigger className="w-full sm:w-56">
<SelectValue />
</SelectTrigger>
<SelectContent>
{(["off", "low", "medium", "high"] as const).map((level) => (
<SelectItem key={level} value={level}>
<span>{t(`reasoning.${level}`)}</span>
<span className="ml-2 text-xs text-muted-foreground">
{t(`reasoning.${level}Desc`)}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="rounded-md border p-3">
<div className="flex items-center justify-between gap-3">
<div className="space-y-1">
<p className="text-sm font-medium">{t("detail.reasoningExpertMode")}</p>
<p className="text-xs text-muted-foreground">
{t("detail.reasoningExpertModeDesc")}
</p>
</div>
<Switch
checked={reasoningExpert}
onCheckedChange={(enabled) => {
setReasoningExpert(enabled);
if (!enabled) {
const legacy = deriveLegacyThinkingLevel(reasoningEffort);
setReasoningThinkingLevel(legacy);
setReasoningFallback("downgrade");
} else if (reasoningEffort === "off" && reasoningThinkingLevel !== "off") {
setReasoningEffort(reasoningThinkingLevel);
}
}}
/>
</div>
<div className="mt-3 space-y-2 text-xs text-muted-foreground">
{reasoningPreviewEntry ? (
<>
<p>
{t("detail.reasoningPreviewDescription", {
model: reasoningPreviewEntry.name || reasoningPreviewEntry.id,
})}
</p>
<div className="space-y-2">
<Label htmlFor="reasoningPreviewModel">{t("detail.reasoningPreviewLabel")}</Label>
<Select value={reasoningPreviewEntry.id} onValueChange={setReasoningPreviewModel}>
<SelectTrigger id="reasoningPreviewModel" className="w-full sm:w-72">
<SelectValue />
</SelectTrigger>
<SelectContent>
{reasoningCapableModels.map((model) => (
<SelectItem key={model.id} value={model.id}>
{model.name || model.id}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{reasoningPreviewCapability?.levels?.length ? (
<div className="flex flex-wrap gap-1">
{reasoningPreviewCapability.levels.map((level) => (
<Badge key={level} variant="outline" className="text-[10px]">
{t(`reasoning.${level}`)}
</Badge>
))}
</div>
) : null}
{reasoningPreviewCapability?.default_effort ? (
<p>
{t("detail.reasoningPreviewDefault", {
level: t(`reasoning.${reasoningPreviewCapability.default_effort}`),
})}
</p>
) : null}
</>
) : (
<p>{t("detail.reasoningPreviewEmpty")}</p>
)}
</div>
{reasoningExpert ? (
<div className="mt-4 space-y-3 border-t pt-3">
<div className="space-y-2">
<Label>{t("detail.reasoningRequestedEffort")}</Label>
<Select value={reasoningEffort} onValueChange={setReasoningEffort}>
<SelectTrigger className="w-full sm:w-72">
<SelectValue />
</SelectTrigger>
<SelectContent>
{ADVANCED_REASONING_LEVELS.map((effort) => (
<SelectItem key={effort} value={effort}>
<span>{t(`reasoning.${effort}`)}</span>
<span className="ml-2 text-xs text-muted-foreground">
{t(`reasoning.${effort}Desc`)}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>{t("detail.reasoningFallbackBehavior")}</Label>
<Select
value={reasoningFallback}
onValueChange={(value) => setReasoningFallback(normalizeReasoningFallback(value))}
>
<SelectTrigger className="w-full sm:w-72">
<SelectValue />
</SelectTrigger>
<SelectContent>
{REASONING_FALLBACKS.map((fallback) => (
<SelectItem key={fallback} value={fallback}>
<span>{t(`reasoning.${fallback}`)}</span>
<span className="ml-2 text-xs text-muted-foreground">
{t(`reasoning.${fallback}Desc`)}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
) : null}
</div>
</section>
<ProviderReasoningSection
reasoningThinkingLevel={reasoningThinkingLevel} setReasoningThinkingLevel={setReasoningThinkingLevel}
reasoningEffort={reasoningEffort} setReasoningEffort={setReasoningEffort}
reasoningFallback={reasoningFallback} setReasoningFallback={(v: string) => setReasoningFallback(v as typeof reasoningFallback)}
reasoningExpert={reasoningExpert} setReasoningExpert={setReasoningExpert}
setReasoningPreviewModel={setReasoningPreviewModel}
reasoningCapableModels={reasoningCapableModels} reasoningPreviewEntry={reasoningPreviewEntry} reasoningPreviewCapability={reasoningPreviewCapability}
/>
) : null}
{canEditPoolRouting ? (
<ChatGPTOAuthRoutingSection
title={t("detail.codexPoolDefaultsTitle")}
description={t("detail.codexPoolDefaultsDescription")}
currentProvider={provider.name}
providers={providers}
value={poolRouting}
onChange={setPoolRouting}
showOverrideMode={false}
canManageProviders
quotaByName={quotaByName}
quotaLoading={quotasLoading || quotasFetching}
entries={poolEntries}
/>
<ChatGPTOAuthRoutingSection title={t("detail.codexPoolDefaultsTitle")} description={t("detail.codexPoolDefaultsDescription")} currentProvider={provider.name} providers={providers} value={poolRouting} onChange={setPoolRouting} showOverrideMode={false} canManageProviders quotaByName={quotaByName} quotaLoading={quotasLoading || quotasFetching} entries={poolEntries} />
) : null}
{isPoolOwner ? (
<ProviderPoolActivitySection
provider={provider}
providerCounts={poolActivity.provider_counts}
recentRequests={poolActivity.recent_requests}
topAgents={poolActivity.top_agents}
statsSampleSize={poolActivity.stats_sample_size}
fetching={poolActivityFetching}
onRefresh={() => void refreshPoolActivity()}
providerByName={providerByName}
statusByName={statusByName}
quotaByName={quotaByName}
/>
<ProviderPoolActivitySection provider={provider} providerCounts={poolActivity.provider_counts} recentRequests={poolActivity.recent_requests} topAgents={poolActivity.top_agents} statsSampleSize={poolActivity.stats_sample_size} fetching={poolActivityFetching} onRefresh={() => void refreshPoolActivity()} providerByName={providerByName} statusByName={statusByName} quotaByName={quotaByName} />
) : null}
{showApiKey ? (
@@ -748,127 +263,28 @@ export function ProviderOverview({ provider, onUpdate }: ProviderOverviewProps)
<h3 className="text-sm font-medium">{t("detail.apiKeySection")}</h3>
<div className="space-y-2">
<Label htmlFor="apiKey">{t("form.apiKey")}</Label>
<Input
id="apiKey"
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder={t("form.apiKeyEditPlaceholder")}
className="text-base md:text-sm"
/>
<Input id="apiKey" type="password" value={apiKey} onChange={(e) => setApiKey(e.target.value)} placeholder={t("form.apiKeyEditPlaceholder")} className="text-base md:text-sm" />
<p className="text-xs text-muted-foreground">{t("form.apiKeySetHint")}</p>
</div>
</section>
) : null}
{showEmbedding ? (
<section className="space-y-3 rounded-lg border p-3 sm:p-4 overflow-hidden">
<h3 className="text-sm font-medium">{t("detail.embeddingSection")}</h3>
<div className="flex items-center justify-between gap-4">
<div className="space-y-0.5">
<Label htmlFor="embEnabled" className="text-sm font-medium">
{t("embedding.enable")}
</Label>
<p className="text-xs text-muted-foreground">{t("embedding.enableDesc")}</p>
</div>
<Switch id="embEnabled" checked={embEnabled} onCheckedChange={setEmbEnabled} />
</div>
{embEnabled ? (
<div className="space-y-3 pt-1">
<div className="space-y-2">
<Label htmlFor="embModel">{t("embedding.model")}</Label>
<Input
id="embModel"
value={embModel}
onChange={(e) => setEmbModel(e.target.value)}
placeholder="text-embedding-3-small"
className="text-base md:text-sm"
/>
</div>
<div className="space-y-2">
<Label>{t("embedding.dimensions")}</Label>
<p className="text-sm text-muted-foreground">1536</p>
<p className="text-xs text-muted-foreground">{t("embedding.dimensionsHint")}</p>
</div>
<div className="space-y-2">
<Label htmlFor="embApiBase">{t("embedding.apiBase")}</Label>
<Input
id="embApiBase"
value={embApiBase}
onChange={(e) => setEmbApiBase(e.target.value)}
placeholder={t("embedding.apiBasePlaceholder")}
className="text-base md:text-sm"
/>
<p className="text-xs text-muted-foreground">{t("embedding.apiBaseHint")}</p>
</div>
<div className="flex items-center gap-3">
<Button
type="button"
variant="outline"
size="sm"
disabled={embVerifying}
onClick={handleVerifyEmbedding}
>
{embVerifying ? <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" /> : null}
{t("embedding.verify")}
</Button>
{embResult ? (
<span
className={`flex items-center gap-1 text-xs ${
embResult.valid
? embResult.dimension_mismatch
? "text-amber-600 dark:text-amber-400"
: "text-success"
: "text-destructive"
}`}
>
{embResult.valid ? (
<>
{embResult.dimension_mismatch ? (
<AlertTriangle className="h-3.5 w-3.5" />
) : (
<CheckCircle2 className="h-3.5 w-3.5" />
)}
{embResult.dimension_mismatch
? t("embedding.dimensionsMismatch", { count: embResult.dimensions })
: `${embResult.dimensions} dimensions`}
</>
) : (
<>
<XCircle className="h-3.5 w-3.5" />
{embResult.error || t("embedding.verifyFailed")}
</>
)}
</span>
) : null}
</div>
</div>
) : null}
</section>
<ProviderEmbeddingSection embEnabled={embEnabled} setEmbEnabled={setEmbEnabled} embModel={embModel} setEmbModel={setEmbModel} embApiBase={embApiBase} setEmbApiBase={setEmbApiBase} onVerify={() => verifyEmbedding(provider.id, embModel.trim() || undefined, undefined)} verifying={embVerifying} verifyResult={embResult} />
) : null}
<section className="space-y-3 rounded-lg border p-3 sm:p-4 overflow-hidden">
<h3 className="text-sm font-medium">{t("detail.statusSection")}</h3>
<div className="flex items-center justify-between gap-4">
<div className="space-y-0.5">
<Label htmlFor="enabled" className="text-sm font-medium">
{t("form.enabled")}
</Label>
<Label htmlFor="enabled" className="text-sm font-medium">{t("form.enabled")}</Label>
<p className="text-xs text-muted-foreground">{t("detail.enabledDesc")}</p>
</div>
<Switch id="enabled" checked={enabled} onCheckedChange={setEnabled} />
</div>
</section>
<StickySaveBar
onSave={handleSave}
saving={saving}
disabled={!isDirty}
/>
<StickySaveBar onSave={handleSave} saving={saving} disabled={!isDirty} />
</div>
);
}
@@ -0,0 +1,202 @@
import { useTranslation } from "react-i18next";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Badge } from "@/components/ui/badge";
import { deriveLegacyThinkingLevel, normalizeReasoningFallback } from "@/types/provider";
import type { ReasoningCapability } from "@/types/provider";
import { ADVANCED_REASONING_LEVELS, REASONING_FALLBACKS } from "./provider-overview-helpers";
interface ReasoningModelEntry {
id: string;
name?: string;
reasoning?: ReasoningCapability | null;
}
interface ProviderReasoningSectionProps {
reasoningThinkingLevel: string;
setReasoningThinkingLevel: (v: string) => void;
reasoningEffort: string;
setReasoningEffort: (v: string) => void;
reasoningFallback: string;
setReasoningFallback: (v: string) => void;
reasoningExpert: boolean;
setReasoningExpert: (v: boolean) => void;
setReasoningPreviewModel: (v: string) => void;
reasoningCapableModels: ReasoningModelEntry[];
reasoningPreviewEntry: ReasoningModelEntry | null;
reasoningPreviewCapability: ReasoningCapability | null;
}
export function ProviderReasoningSection({
reasoningThinkingLevel,
setReasoningThinkingLevel,
reasoningEffort,
setReasoningEffort,
reasoningFallback,
setReasoningFallback,
reasoningExpert,
setReasoningExpert,
setReasoningPreviewModel,
reasoningCapableModels,
reasoningPreviewEntry,
reasoningPreviewCapability,
}: ProviderReasoningSectionProps) {
const { t } = useTranslation("providers");
return (
<section className="space-y-4 rounded-lg border p-3 sm:p-4 overflow-hidden">
<div className="space-y-1">
<h3 className="text-sm font-medium">{t("detail.reasoningDefaultsTitle")}</h3>
<p className="text-xs text-muted-foreground">
{t("detail.reasoningDefaultsDescription")}
</p>
</div>
<div className="space-y-2">
<Label>{t("detail.reasoningPreset")}</Label>
<Select
value={reasoningThinkingLevel}
onValueChange={(value) => {
setReasoningThinkingLevel(value);
setReasoningEffort(value);
}}
>
<SelectTrigger className="w-full sm:w-56">
<SelectValue />
</SelectTrigger>
<SelectContent>
{(["off", "low", "medium", "high"] as const).map((level) => (
<SelectItem key={level} value={level}>
<span>{t(`reasoning.${level}`)}</span>
<span className="ml-2 text-xs text-muted-foreground">
{t(`reasoning.${level}Desc`)}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="rounded-md border p-3">
<div className="flex items-center justify-between gap-3">
<div className="space-y-1">
<p className="text-sm font-medium">{t("detail.reasoningExpertMode")}</p>
<p className="text-xs text-muted-foreground">
{t("detail.reasoningExpertModeDesc")}
</p>
</div>
<Switch
checked={reasoningExpert}
onCheckedChange={(enabled) => {
setReasoningExpert(enabled);
if (!enabled) {
const legacy = deriveLegacyThinkingLevel(reasoningEffort);
setReasoningThinkingLevel(legacy);
setReasoningFallback("downgrade");
} else if (reasoningEffort === "off" && reasoningThinkingLevel !== "off") {
setReasoningEffort(reasoningThinkingLevel);
}
}}
/>
</div>
<div className="mt-3 space-y-2 text-xs text-muted-foreground">
{reasoningPreviewEntry ? (
<>
<p>
{t("detail.reasoningPreviewDescription", {
model: reasoningPreviewEntry.name || reasoningPreviewEntry.id,
})}
</p>
<div className="space-y-2">
<Label htmlFor="reasoningPreviewModel">{t("detail.reasoningPreviewLabel")}</Label>
<Select value={reasoningPreviewEntry.id} onValueChange={setReasoningPreviewModel}>
<SelectTrigger id="reasoningPreviewModel" className="w-full sm:w-72">
<SelectValue />
</SelectTrigger>
<SelectContent>
{reasoningCapableModels.map((model) => (
<SelectItem key={model.id} value={model.id}>
{model.name || model.id}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{reasoningPreviewCapability?.levels?.length ? (
<div className="flex flex-wrap gap-1">
{reasoningPreviewCapability.levels.map((level) => (
<Badge key={level} variant="outline" className="text-[10px]">
{t(`reasoning.${level}`)}
</Badge>
))}
</div>
) : null}
{reasoningPreviewCapability?.default_effort ? (
<p>
{t("detail.reasoningPreviewDefault", {
level: t(`reasoning.${reasoningPreviewCapability.default_effort}`),
})}
</p>
) : null}
</>
) : (
<p>{t("detail.reasoningPreviewEmpty")}</p>
)}
</div>
{reasoningExpert ? (
<div className="mt-4 space-y-3 border-t pt-3">
<div className="space-y-2">
<Label>{t("detail.reasoningRequestedEffort")}</Label>
<Select value={reasoningEffort} onValueChange={setReasoningEffort}>
<SelectTrigger className="w-full sm:w-72">
<SelectValue />
</SelectTrigger>
<SelectContent>
{ADVANCED_REASONING_LEVELS.map((effort) => (
<SelectItem key={effort} value={effort}>
<span>{t(`reasoning.${effort}`)}</span>
<span className="ml-2 text-xs text-muted-foreground">
{t(`reasoning.${effort}Desc`)}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>{t("detail.reasoningFallbackBehavior")}</Label>
<Select
value={reasoningFallback}
onValueChange={(value) => setReasoningFallback(normalizeReasoningFallback(value))}
>
<SelectTrigger className="w-full sm:w-72">
<SelectValue />
</SelectTrigger>
<SelectContent>
{REASONING_FALLBACKS.map((fallback) => (
<SelectItem key={fallback} value={fallback}>
<span>{t(`reasoning.${fallback}`)}</span>
<span className="ml-2 text-xs text-muted-foreground">
{t(`reasoning.${fallback}Desc`)}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
) : null}
</div>
</section>
);
}
@@ -1,10 +1,9 @@
import { useState, useEffect, useCallback, useRef, useLayoutEffect } from "react";
import { useState, useEffect, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { ArrowLeft, Trash2, RotateCcw, Info, Eye, Pencil, Check, X } from "lucide-react";
import { ArrowLeft, Trash2, RotateCcw, Eye, Pencil, Check, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { MessageBubble } from "@/components/chat/message-bubble";
import { MarkdownRenderer } from "@/components/shared/markdown-renderer";
import { ConfirmDialog } from "@/components/shared/confirm-dialog";
import { useWsEvent } from "@/hooks/use-ws-event";
import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
@@ -14,6 +13,7 @@ import { formatDate, formatTokens } from "@/lib/format";
import type { SessionInfo, SessionPreview, Message } from "@/types/session";
import type { ChatMessage, AgentEventPayload, ToolStreamEntry } from "@/types/chat";
import { messageToTimestamp } from "@/lib/message-utils";
import { SystemMessageBlock, SummaryBlock } from "./session-message-blocks";
/** Check if a message is an internal system message (subagent results, cron, etc.) */
function isSystemMessage(msg: ChatMessage): boolean {
@@ -273,73 +273,3 @@ export function SessionDetailPage({
);
}
function SystemMessageBlock({ content }: { content: string }) {
const { t } = useTranslation("sessions");
const [expanded, setExpanded] = useState(false);
// Extract the first line as title, rest as body
const lines = content.split("\n");
const title = (lines[0] ?? "").replace(/^\[System Message\]\s*/, "").trim();
const body = lines.slice(1).join("\n").trim();
return (
<div className="mx-auto flex max-w-3xl items-start gap-2 rounded-md border border-dashed border-muted-foreground/30 bg-muted/30 px-4 py-2">
<Info className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
<div className="min-w-0 text-xs text-muted-foreground">
<span className="font-medium">{title || t("detail.systemMessage")}</span>
{body && (
<>
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="ml-1 cursor-pointer text-primary hover:underline"
>
{expanded ? t("detail.hide") : t("detail.showDetails")}
</button>
{expanded && (
<div className="mt-2">
<MarkdownRenderer content={body} className="text-xs" />
</div>
)}
</>
)}
</div>
</div>
);
}
const SUMMARY_MAX_HEIGHT = 72; // ~3 lines of text
function SummaryBlock({ text }: { text: string }) {
const { t } = useTranslation("sessions");
const [expanded, setExpanded] = useState(false);
const [needsTruncation, setNeedsTruncation] = useState(false);
const contentRef = useRef<HTMLDivElement>(null);
useLayoutEffect(() => {
if (contentRef.current) {
setNeedsTruncation(contentRef.current.scrollHeight > SUMMARY_MAX_HEIGHT);
}
}, [text]);
return (
<div className="border-b bg-muted/50 px-6 py-3 text-sm">
<span className="font-medium">{t("detail.summary")}: </span>
<div
ref={contentRef}
className="mt-1 overflow-hidden transition-[max-height] duration-200"
style={{ maxHeight: expanded ? contentRef.current?.scrollHeight : SUMMARY_MAX_HEIGHT }}
>
{text}
</div>
{needsTruncation && (
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="mt-1 cursor-pointer text-xs font-medium text-primary hover:underline"
>
{expanded ? t("detail.showLess") : t("detail.showMore")}
</button>
)}
</div>
);
}
@@ -0,0 +1,80 @@
/**
* Sub-components for session detail view:
* - SystemMessageBlock: collapsible system/internal message display
* - SummaryBlock: collapsible session summary with truncation
*/
import { useState, useRef, useLayoutEffect } from "react";
import { useTranslation } from "react-i18next";
import { Info } from "lucide-react";
import { MarkdownRenderer } from "@/components/shared/markdown-renderer";
export function SystemMessageBlock({ content }: { content: string }) {
const { t } = useTranslation("sessions");
const [expanded, setExpanded] = useState(false);
// Extract the first line as title, rest as body
const lines = content.split("\n");
const title = (lines[0] ?? "").replace(/^\[System Message\]\s*/, "").trim();
const body = lines.slice(1).join("\n").trim();
return (
<div className="mx-auto flex max-w-3xl items-start gap-2 rounded-md border border-dashed border-muted-foreground/30 bg-muted/30 px-4 py-2">
<Info className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
<div className="min-w-0 text-xs text-muted-foreground">
<span className="font-medium">{title || t("detail.systemMessage")}</span>
{body && (
<>
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="ml-1 cursor-pointer text-primary hover:underline"
>
{expanded ? t("detail.hide") : t("detail.showDetails")}
</button>
{expanded && (
<div className="mt-2">
<MarkdownRenderer content={body} className="text-xs" />
</div>
)}
</>
)}
</div>
</div>
);
}
const SUMMARY_MAX_HEIGHT = 72; // ~3 lines of text
export function SummaryBlock({ text }: { text: string }) {
const { t } = useTranslation("sessions");
const [expanded, setExpanded] = useState(false);
const [needsTruncation, setNeedsTruncation] = useState(false);
const contentRef = useRef<HTMLDivElement>(null);
useLayoutEffect(() => {
if (contentRef.current) {
setNeedsTruncation(contentRef.current.scrollHeight > SUMMARY_MAX_HEIGHT);
}
}, [text]);
return (
<div className="border-b bg-muted/50 px-6 py-3 text-sm">
<span className="font-medium">{t("detail.summary")}: </span>
<div
ref={contentRef}
className="mt-1 overflow-hidden transition-[max-height] duration-200"
style={{ maxHeight: expanded ? contentRef.current?.scrollHeight : SUMMARY_MAX_HEIGHT }}
>
{text}
</div>
{needsTruncation && (
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="mt-1 cursor-pointer text-xs font-medium text-primary hover:underline"
>
{expanded ? t("detail.showLess") : t("detail.showMore")}
</button>
)}
</div>
);
}
+150
View File
@@ -0,0 +1,150 @@
import { useTranslation } from "react-i18next";
import { Zap, Pencil, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Switch } from "@/components/ui/switch";
import { cn } from "@/lib/utils";
import { SkillTenantOverride } from "./skill-tenant-override";
import type { SkillInfo } from "./hooks/use-skills";
const visibilityColor: Record<string, string> = {
public: "default",
internal: "secondary",
private: "outline",
};
interface SkillTableRowProps {
skill: SkillInfo;
tab: "core" | "custom";
hasTenantScope: boolean;
toggling: string | null;
onView: (name: string) => void;
onEdit: (skill: SkillInfo) => void;
onDelete: (skill: SkillInfo) => void;
onToggle: (skill: SkillInfo, enabled: boolean) => void;
onCycleVisibility: (skill: SkillInfo) => void;
onSetTenantConfig: (id: string, enabled: boolean) => Promise<void>;
onDeleteTenantConfig: (id: string) => Promise<void>;
}
/** Single row in the skills table with inline status, visibility, and action controls. */
export function SkillTableRow({
skill, tab, hasTenantScope, toggling,
onView, onEdit, onDelete, onToggle, onCycleVisibility,
onSetTenantConfig, onDeleteTenantConfig,
}: SkillTableRowProps) {
const { t } = useTranslation("skills");
const isArchived = skill.status === "archived";
const isDisabled = skill.enabled === false;
const hasMissing = (skill.missing_deps?.length ?? 0) > 0;
return (
<tr className={cn("border-b last:border-0 hover:bg-muted/30", (isArchived || isDisabled) && "opacity-60")}>
<td className="px-4 py-3">
<div className="flex items-center gap-2 flex-wrap">
<Zap className="h-4 w-4 text-muted-foreground shrink-0" />
<button
type="button"
className="font-medium text-left hover:underline cursor-pointer"
onClick={() => onView(skill.slug ?? skill.name)}
>
{skill.name}
</button>
{skill.is_system && (
<Badge variant="outline" className="border-blue-500 text-blue-600 text-[10px]">
{t("system")}
</Badge>
)}
{skill.version && <span className="text-xs text-muted-foreground">v{skill.version}</span>}
</div>
</td>
<td className="max-w-xs truncate px-4 py-3 text-muted-foreground">
{skill.description || t("noDescription")}
</td>
{tab === "custom" && (
<td className="px-4 py-3 text-sm text-muted-foreground">{skill.author || "—"}</td>
)}
<td className="px-4 py-3">
<div className="flex flex-col gap-1">
<Badge
variant="outline"
className={cn(
"text-[10px] w-fit",
isArchived
? "border-amber-500 text-amber-600 dark:border-amber-600 dark:text-amber-400"
: "border-emerald-500 text-emerald-600 dark:border-emerald-600 dark:text-emerald-400",
)}
>
{isArchived ? t("deps.statusArchived") : t("deps.statusActive")}
</Badge>
{hasMissing && (() => {
const deps = skill.missing_deps!.map((d) => d.replace(/^(pip|npm):/, ""));
const shown = deps.slice(0, 3);
const rest = deps.length - shown.length;
return (
<span className="text-[10px] text-amber-600 dark:text-amber-400 leading-tight">
{shown.join(", ")}{rest > 0 && `, +${rest}`}
</span>
);
})()}
</div>
</td>
{tab === "custom" && (
<td className="px-4 py-3">
{skill.visibility && (
skill.id ? (
<button type="button" onClick={() => onCycleVisibility(skill)} title={t("visibility.clickToCycle")}>
<Badge
variant={visibilityColor[skill.visibility] as "default" | "secondary" | "outline"}
className="cursor-pointer hover:opacity-80 transition-opacity"
>
{skill.visibility}
</Badge>
</button>
) : (
<Badge variant={visibilityColor[skill.visibility] as "default" | "secondary" | "outline"}>
{skill.visibility}
</Badge>
)
)}
</td>
)}
<td className="px-4 py-3 text-right">
<div className="flex items-center justify-end gap-2">
{skill.id && (
<>
{hasTenantScope ? (
<SkillTenantOverride
skill={skill}
toggling={toggling === skill.id}
onSetTenantConfig={onSetTenantConfig}
onDeleteTenantConfig={onDeleteTenantConfig}
/>
) : (
<Switch
size="sm"
checked={skill.enabled !== false}
disabled={toggling === skill.id}
onCheckedChange={(checked) => onToggle(skill, checked)}
title={skill.enabled !== false ? t("toggle.disable") : t("toggle.enable")}
/>
)}
<Button variant="ghost" size="sm" onClick={() => onEdit(skill)} className="gap-1">
<Pencil className="h-3.5 w-3.5" />
</Button>
{!skill.is_system && (
<Button
variant="ghost" size="sm"
onClick={() => onDelete(skill)}
className="gap-1 text-destructive hover:text-destructive"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
)}
</>
)}
</div>
</td>
</tr>
);
}
@@ -0,0 +1,79 @@
import { useTranslation } from "react-i18next";
import { X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Switch } from "@/components/ui/switch";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import type { SkillInfo } from "./hooks/use-skills";
interface SkillTenantOverrideProps {
skill: SkillInfo;
toggling: boolean;
onSetTenantConfig: (id: string, enabled: boolean) => Promise<void>;
onDeleteTenantConfig: (id: string) => Promise<void>;
}
/** Per-tenant enable/disable override control shown when a tenant scope is active. */
export function SkillTenantOverride({
skill,
toggling,
onSetTenantConfig,
onDeleteTenantConfig,
}: SkillTenantOverrideProps) {
const { t } = useTranslation("skills");
const hasOverride = skill.tenant_enabled !== null && skill.tenant_enabled !== undefined;
const checked = hasOverride ? (skill.tenant_enabled ?? false) : (skill.enabled !== false);
const label = hasOverride
? skill.tenant_enabled ? t("tenant.enabled") : t("tenant.disabled")
: t("tenant.default");
const badgeVariant = hasOverride
? skill.tenant_enabled ? "default" : "secondary"
: "outline";
return (
<div className="flex items-center gap-1">
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<Badge
variant={badgeVariant as "default" | "secondary" | "outline"}
className="h-5 cursor-default px-1.5 text-[10px] leading-none"
>
{label}
</Badge>
</TooltipTrigger>
<TooltipContent side="top">
<p className="text-xs">{t("tenant.overrideHint")}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<Switch
size="sm"
checked={checked}
disabled={toggling}
onCheckedChange={(val) => onSetTenantConfig(skill.id!, val)}
aria-label={t("tenant.overrideHint")}
/>
{hasOverride && (
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
disabled={toggling}
onClick={() => onDeleteTenantConfig(skill.id!)}
className="h-5 w-5 p-0 text-muted-foreground hover:text-foreground"
>
<X className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
<p className="text-xs">{t("tenant.resetDefault")}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
);
}
+52 -283
View File
@@ -1,10 +1,7 @@
import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Zap, Pencil, RefreshCw, Upload, Trash2, ScanSearch, X } from "lucide-react";
import { Zap, RefreshCw, Upload, ScanSearch } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Switch } from "@/components/ui/switch";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { PageHeader } from "@/components/shared/page-header";
import { EmptyState } from "@/components/shared/empty-state";
import { SearchInput } from "@/components/shared/search-input";
@@ -17,6 +14,7 @@ import { SkillDetailDialog } from "./skill-detail-dialog";
import { SkillUploadDialog } from "./skill-upload-dialog";
import { SkillEditDialog } from "./skill-edit-dialog";
import { MissingDepsPanel } from "./missing-deps-panel";
import { SkillTableRow } from "./skill-table-row";
import { useRuntimes } from "./hooks/use-runtimes";
import { useMinLoading } from "@/hooks/use-min-loading";
import { useDeferredLoading } from "@/hooks/use-deferred-loading";
@@ -25,12 +23,6 @@ import { useTenants } from "@/hooks/use-tenants";
const MASTER_TENANT_ID = "0193a5b0-7000-7000-8000-000000000001";
const visibilityColor: Record<string, string> = {
public: "default",
internal: "secondary",
private: "outline",
};
type Tab = "core" | "custom";
export function SkillsPage() {
@@ -58,15 +50,12 @@ export function SkillsPage() {
const coreSkills = skills.filter((s: SkillInfo) => s.is_system);
const customSkills = skills.filter((s: SkillInfo) => !s.is_system);
const tabSkills = tab === "core" ? coreSkills : customSkills;
const allMissing = [...new Set(tabSkills.flatMap((s: SkillInfo) => s.missing_deps ?? []))];
const filtered = tabSkills.filter(
(s: SkillInfo) =>
s.name.toLowerCase().includes(search.toLowerCase()) ||
s.description.toLowerCase().includes(search.toLowerCase()),
);
const { pageItems, pagination, setPage, setPageSize, resetPage } = usePagination(filtered);
useEffect(() => { resetPage(); }, [search, tab, resetPage]);
@@ -76,47 +65,39 @@ export function SkillsPage() {
if (detail) setSelectedSkill(detail);
};
const handleUpload = async (file: File) => {
return uploadSkill(file);
};
const handleCycleVisibility = async (skill: SkillInfo) => {
if (!skill.id) return;
const order = ["private", "internal", "public"] as const;
const idx = order.indexOf(skill.visibility as typeof order[number]);
const next = order[(idx + 1) % order.length];
await updateSkill(skill.id, { visibility: next });
await updateSkill(skill.id, { visibility: order[(idx + 1) % order.length] });
};
const handleDelete = async () => {
if (!deleteTarget?.id) return;
setDeleteLoading(true);
try {
await deleteSkill(deleteTarget.id);
setDeleteTarget(null);
refresh();
} finally {
setDeleteLoading(false);
}
try { await deleteSkill(deleteTarget.id); setDeleteTarget(null); refresh(); }
finally { setDeleteLoading(false); }
};
const handleRescanDeps = async () => {
setRescanning(true);
try {
await rescanDeps();
} finally {
setRescanning(false);
}
try { await rescanDeps(); } finally { setRescanning(false); }
};
const handleToggle = async (skill: SkillInfo, enabled: boolean) => {
if (!skill.id) return;
setToggling(skill.id);
try {
await toggleSkill(skill.id, enabled);
} finally {
setToggling(null);
}
try { await toggleSkill(skill.id, enabled); } finally { setToggling(null); }
};
const handleSetTenantConfig = async (id: string, enabled: boolean) => {
setToggling(id);
try { await setTenantConfig(id, enabled); } finally { setToggling(null); }
};
const handleDeleteTenantConfig = async (id: string) => {
setToggling(id);
try { await deleteTenantConfig(id); } finally { setToggling(null); }
};
return (
@@ -141,47 +122,27 @@ export function SkillsPage() {
}
/>
{/* Tabs */}
<div className="flex gap-1 border-b mt-4">
<button
type="button"
className={cn(
"px-3 py-1.5 text-sm font-medium border-b-2 -mb-px",
tab === "core"
? "border-primary text-primary"
: "border-transparent text-muted-foreground hover:text-foreground",
)}
onClick={() => setTab("core")}
>
{t("tabs.core")} ({coreSkills.length})
</button>
<button
type="button"
className={cn(
"px-3 py-1.5 text-sm font-medium border-b-2 -mb-px",
tab === "custom"
? "border-primary text-primary"
: "border-transparent text-muted-foreground hover:text-foreground",
)}
onClick={() => setTab("custom")}
>
{t("tabs.custom")} ({customSkills.length})
</button>
{(["core", "custom"] as Tab[]).map((tabKey) => (
<button
key={tabKey}
type="button"
className={cn(
"px-3 py-1.5 text-sm font-medium border-b-2 -mb-px",
tab === tabKey
? "border-primary text-primary"
: "border-transparent text-muted-foreground hover:text-foreground",
)}
onClick={() => setTab(tabKey)}
>
{t(`tabs.${tabKey}`)} ({tabKey === "core" ? coreSkills.length : customSkills.length})
</button>
))}
</div>
<div className="mt-4">
<MissingDepsPanel
missing={allMissing}
onInstallItem={installSingleDep}
runtimes={tab === "core" ? runtimes : undefined}
/>
<SearchInput
value={search}
onChange={setSearch}
placeholder={t("searchPlaceholder")}
className="max-w-sm"
/>
<MissingDepsPanel missing={allMissing} onInstallItem={installSingleDep} runtimes={tab === "core" ? runtimes : undefined} />
<SearchInput value={search} onChange={setSearch} placeholder={t("searchPlaceholder")} className="max-w-sm" />
</div>
<div className="mt-4">
@@ -207,138 +168,22 @@ export function SkillsPage() {
</tr>
</thead>
<tbody>
{pageItems.map((skill: SkillInfo) => {
const isArchived = skill.status === "archived";
const isDisabled = skill.enabled === false;
const hasMissing = (skill.missing_deps?.length ?? 0) > 0;
return (
<tr key={skill.name} className={cn("border-b last:border-0 hover:bg-muted/30", (isArchived || isDisabled) && "opacity-60")}>
<td className="px-4 py-3">
<div className="flex items-center gap-2 flex-wrap">
<Zap className="h-4 w-4 text-muted-foreground shrink-0" />
<button
type="button"
className="font-medium text-left hover:underline cursor-pointer"
onClick={() => handleViewSkill(skill.slug ?? skill.name)}
>
{skill.name}
</button>
{skill.is_system && (
<Badge variant="outline" className="border-blue-500 text-blue-600 text-[10px]">
{t("system")}
</Badge>
)}
{skill.version ? (
<span className="text-xs text-muted-foreground">v{skill.version}</span>
) : null}
</div>
</td>
<td className="max-w-xs truncate px-4 py-3 text-muted-foreground">
{skill.description || t("noDescription")}
</td>
{tab === "custom" && (
<td className="px-4 py-3 text-sm text-muted-foreground">
{skill.author || "—"}
</td>
)}
<td className="px-4 py-3">
<div className="flex flex-col gap-1">
<Badge
variant="outline"
className={cn(
"text-[10px] w-fit",
isArchived
? "border-amber-500 text-amber-600 dark:border-amber-600 dark:text-amber-400"
: "border-emerald-500 text-emerald-600 dark:border-emerald-600 dark:text-emerald-400",
)}
>
{isArchived ? t("deps.statusArchived") : t("deps.statusActive")}
</Badge>
{hasMissing && (() => {
const deps = skill.missing_deps!.map((d) => d.replace(/^(pip|npm):/, ""));
const shown = deps.slice(0, 3);
const rest = deps.length - shown.length;
return (
<span className="text-[10px] text-amber-600 dark:text-amber-400 leading-tight">
{shown.join(", ")}{rest > 0 && `, +${rest}`}
</span>
);
})()}
</div>
</td>
{tab === "custom" && <td className="px-4 py-3">
{skill.visibility && (
skill.id ? (
<button
type="button"
onClick={() => handleCycleVisibility(skill)}
title={t("visibility.clickToCycle")}
>
<Badge
variant={visibilityColor[skill.visibility] as "default" | "secondary" | "outline"}
className="cursor-pointer hover:opacity-80 transition-opacity"
>
{skill.visibility}
</Badge>
</button>
) : (
<Badge variant={visibilityColor[skill.visibility] as "default" | "secondary" | "outline"}>
{skill.visibility}
</Badge>
)
)}
</td>}
<td className="px-4 py-3 text-right">
<div className="flex items-center justify-end gap-2">
{skill.id && (
<>
{hasTenantScope ? (
<SkillTenantOverride
skill={skill}
toggling={toggling === skill.id}
onSetTenantConfig={async (id, enabled) => {
setToggling(id);
try { await setTenantConfig(id, enabled); } finally { setToggling(null); }
}}
onDeleteTenantConfig={async (id) => {
setToggling(id);
try { await deleteTenantConfig(id); } finally { setToggling(null); }
}}
/>
) : (
<Switch
size="sm"
checked={skill.enabled !== false}
disabled={toggling === skill.id}
onCheckedChange={(checked) => handleToggle(skill, checked)}
title={skill.enabled !== false ? t("toggle.disable") : t("toggle.enable")}
/>
)}
<Button
variant="ghost"
size="sm"
onClick={() => setEditTarget(skill)}
className="gap-1"
>
<Pencil className="h-3.5 w-3.5" />
</Button>
{!skill.is_system && (
<Button
variant="ghost"
size="sm"
onClick={() => setDeleteTarget(skill)}
className="gap-1 text-destructive hover:text-destructive"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
)}
</>
)}
</div>
</td>
</tr>
);
})}
{pageItems.map((skill: SkillInfo) => (
<SkillTableRow
key={skill.name}
skill={skill}
tab={tab}
hasTenantScope={hasTenantScope}
toggling={toggling}
onView={handleViewSkill}
onEdit={setEditTarget}
onDelete={setDeleteTarget}
onToggle={handleToggle}
onCycleVisibility={handleCycleVisibility}
onSetTenantConfig={handleSetTenantConfig}
onDeleteTenantConfig={handleDeleteTenantConfig}
/>
))}
</tbody>
</table>
<Pagination
@@ -367,18 +212,11 @@ export function SkillsPage() {
<SkillEditDialog
skill={editTarget}
onClose={() => setEditTarget(null)}
onSave={async (id, updates) => {
await updateSkill(id, updates);
setEditTarget(null);
}}
onSave={async (id, updates) => { await updateSkill(id, updates); setEditTarget(null); }}
/>
)}
<SkillUploadDialog
open={uploadOpen}
onOpenChange={setUploadOpen}
onUpload={handleUpload}
/>
<SkillUploadDialog open={uploadOpen} onOpenChange={setUploadOpen} onUpload={(f) => uploadSkill(f)} />
<ConfirmDeleteDialog
open={!!deleteTarget}
@@ -393,72 +231,3 @@ export function SkillsPage() {
</div>
);
}
function SkillTenantOverride({
skill,
toggling,
onSetTenantConfig,
onDeleteTenantConfig,
}: {
skill: SkillInfo;
toggling: boolean;
onSetTenantConfig: (id: string, enabled: boolean) => Promise<void>;
onDeleteTenantConfig: (id: string) => Promise<void>;
}) {
const { t } = useTranslation("skills");
const hasOverride = skill.tenant_enabled !== null && skill.tenant_enabled !== undefined;
const checked = hasOverride ? (skill.tenant_enabled ?? false) : (skill.enabled !== false);
const label = hasOverride
? skill.tenant_enabled ? t("tenant.enabled") : t("tenant.disabled")
: t("tenant.default");
const badgeVariant = hasOverride
? skill.tenant_enabled ? "default" : "secondary"
: "outline";
return (
<div className="flex items-center gap-1">
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<Badge
variant={badgeVariant as "default" | "secondary" | "outline"}
className="h-5 cursor-default px-1.5 text-[10px] leading-none"
>
{label}
</Badge>
</TooltipTrigger>
<TooltipContent side="top">
<p className="text-xs">{t("tenant.overrideHint")}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<Switch
size="sm"
checked={checked}
disabled={toggling}
onCheckedChange={(val) => onSetTenantConfig(skill.id!, val)}
aria-label={t("tenant.overrideHint")}
/>
{hasOverride && (
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
disabled={toggling}
onClick={() => onDeleteTenantConfig(skill.id!)}
className="h-5 w-5 p-0 text-muted-foreground hover:text-foreground"
>
<X className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
<p className="text-xs">{t("tenant.resetDefault")}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
);
}
@@ -0,0 +1,64 @@
import { useTranslation } from "react-i18next";
import { MultiUserPicker } from "@/components/shared/multi-user-picker";
import { MultiSelect } from "./team-multi-select";
interface TeamAccessControlSectionProps {
allowUserIds: string[];
setAllowUserIds: (v: string[]) => void;
denyUserIds: string[];
setDenyUserIds: (v: string[]) => void;
allowChannels: string[];
setAllowChannels: (v: string[]) => void;
denyChannels: string[];
setDenyChannels: (v: string[]) => void;
channelOptions: { value: string; label?: string }[];
}
/** User and channel allow/deny lists for team access control settings. */
export function TeamAccessControlSection({
allowUserIds, setAllowUserIds,
denyUserIds, setDenyUserIds,
allowChannels, setAllowChannels,
denyChannels, setDenyChannels,
channelOptions,
}: TeamAccessControlSectionProps) {
const { t } = useTranslation("teams");
return (
<>
{/* User Access Control */}
<div className="space-y-4">
<h3 className="text-sm font-medium">{t("settings.userAccessControl")}</h3>
<div className="space-y-3 rounded-lg border p-4">
<div className="space-y-1.5">
<label className="text-sm font-medium">{t("settings.allowedUsers")}</label>
<p className="text-xs text-muted-foreground">{t("settings.allowedUsersHint")}</p>
<MultiUserPicker value={allowUserIds} onChange={setAllowUserIds} placeholder={t("settings.searchUsers")} />
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium">{t("settings.deniedUsers")}</label>
<p className="text-xs text-muted-foreground">{t("settings.deniedUsersHint")}</p>
<MultiUserPicker value={denyUserIds} onChange={setDenyUserIds} placeholder={t("settings.searchUsers")} />
</div>
</div>
</div>
{/* Channel Restrictions */}
<div className="space-y-4">
<h3 className="text-sm font-medium">{t("settings.channelRestrictions")}</h3>
<div className="space-y-3 rounded-lg border p-4">
<div className="space-y-1.5">
<label className="text-sm font-medium">{t("settings.allowedChannels")}</label>
<p className="text-xs text-muted-foreground">{t("settings.allowedChannelsHint")}</p>
<MultiSelect options={channelOptions} selected={allowChannels} onChange={setAllowChannels} placeholder={t("settings.selectChannel")} />
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium">{t("settings.deniedChannels")}</label>
<p className="text-xs text-muted-foreground">{t("settings.deniedChannelsHint")}</p>
<MultiSelect options={channelOptions} selected={denyChannels} onChange={setDenyChannels} placeholder={t("settings.selectChannel")} />
</div>
</div>
</div>
</>
);
}
@@ -0,0 +1,47 @@
import { X } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Combobox } from "@/components/ui/combobox";
/** Generic multi-select with static options (used for channel type selection). */
export function MultiSelect({
options,
selected,
onChange,
placeholder,
}: {
options: { value: string; label?: string }[];
selected: string[];
onChange: (values: string[]) => void;
placeholder: string;
}) {
return (
<div className="space-y-2">
<Combobox
value=""
onChange={(val) => {
if (val && !selected.includes(val)) {
onChange([...selected, val]);
}
}}
options={options.filter((o) => !selected.includes(o.value))}
placeholder={placeholder}
/>
{selected.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{selected.map((id) => (
<Badge key={id} variant="secondary" className="gap-1 pr-1">
{options.find((o) => o.value === id)?.label ?? id}
<button
type="button"
onClick={() => onChange(selected.filter((s) => s !== id))}
className="ml-0.5 cursor-pointer rounded-full p-0.5 hover:bg-muted"
>
<X className="h-3 w-3" />
</button>
</Badge>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,105 @@
import { Switch } from "@/components/ui/switch";
import { Bell, Zap, Bot } from "lucide-react";
import { useTranslation } from "react-i18next";
interface TeamNotificationsSectionProps {
notifyDispatched: boolean;
setNotifyDispatched: (v: boolean) => void;
notifyProgress: boolean;
setNotifyProgress: (v: boolean) => void;
notifyFailed: boolean;
setNotifyFailed: (v: boolean) => void;
notifyCompleted: boolean;
setNotifyCompleted: (v: boolean) => void;
notifyCommented: boolean;
setNotifyCommented: (v: boolean) => void;
notifyNewTask: boolean;
setNotifyNewTask: (v: boolean) => void;
notifySlowTool: boolean;
setNotifySlowTool: (v: boolean) => void;
notifyMode: "direct" | "leader";
setNotifyMode: (v: "direct" | "leader") => void;
}
/** Notification toggles and delivery mode selector for team settings. */
export function TeamNotificationsSection({
notifyDispatched, setNotifyDispatched,
notifyProgress, setNotifyProgress,
notifyFailed, setNotifyFailed,
notifyCompleted, setNotifyCompleted,
notifyCommented, setNotifyCommented,
notifyNewTask, setNotifyNewTask,
notifySlowTool, setNotifySlowTool,
notifyMode, setNotifyMode,
}: TeamNotificationsSectionProps) {
const { t } = useTranslation("teams");
const modeOptions = [
{ value: "direct" as const, Icon: Zap, labelKey: "notifyModeDirect", descKey: "notifyModeDirectDesc" },
{ value: "leader" as const, Icon: Bot, labelKey: "notifyModeLeader", descKey: "notifyModeLeaderDesc" },
];
return (
<div className="space-y-4">
<h3 className="text-sm font-medium">{t("settings.notifications")}</h3>
<div className="rounded-lg border bg-gradient-to-r from-blue-500/5 to-orange-500/5 p-4 space-y-3">
<div className="flex items-start gap-4">
<div className="rounded-lg bg-blue-500/10 p-2.5 text-blue-600 dark:text-blue-400">
<Bell className="h-5 w-5" />
</div>
<div className="flex-1 space-y-3">
{[
{ label: t("settings.notifyDispatched"), hint: t("settings.notifyDispatchedHint"), checked: notifyDispatched, onChange: setNotifyDispatched },
{ label: t("settings.notifyProgress"), hint: t("settings.notifyProgressHint"), checked: notifyProgress, onChange: setNotifyProgress },
{ label: t("settings.notifyFailed"), hint: t("settings.notifyFailedHint"), checked: notifyFailed, onChange: setNotifyFailed },
{ label: t("settings.notifyCompleted"), hint: undefined, checked: notifyCompleted, onChange: setNotifyCompleted },
{ label: t("settings.notifyCommented"), hint: undefined, checked: notifyCommented, onChange: setNotifyCommented },
{ label: t("settings.notifyNewTask"), hint: undefined, checked: notifyNewTask, onChange: setNotifyNewTask },
{ label: t("settings.notifySlowTool"), hint: t("settings.notifySlowToolHint"), checked: notifySlowTool, onChange: setNotifySlowTool },
].map(({ label, hint, checked, onChange }) => (
<div key={label} className="flex items-center justify-between">
<div>
<span className="text-sm font-semibold">{label}</span>
{hint && <p className="text-xs text-muted-foreground">{hint}</p>}
</div>
<Switch checked={checked} onCheckedChange={onChange} />
</div>
))}
<div className="border-t pt-3 space-y-2">
<span className="text-sm font-semibold">{t("settings.notifyMode")}</span>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{modeOptions.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => setNotifyMode(opt.value)}
className={
"flex items-start gap-3 rounded-lg border p-3 text-left transition-colors cursor-pointer " +
(notifyMode === opt.value
? "border-primary bg-primary/5"
: "border-border hover:border-primary/50")
}
>
<opt.Icon className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
<div>
<div className="text-sm font-medium">{t(`settings.${opt.labelKey}`)}</div>
<div className="mt-0.5 text-xs text-muted-foreground">
{t(`settings.${opt.descKey}`)}
</div>
</div>
</button>
))}
</div>
{notifyMode === "leader" && (
<p className="text-xs text-amber-600 dark:text-amber-400">
⚠️ {t("settings.notifyModeLeaderWarning")}
</p>
)}
</div>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,154 @@
import { useTranslation } from "react-i18next";
import { Switch } from "@/components/ui/switch";
import { ShieldAlert, Clock, FolderLock, FolderSync } from "lucide-react";
interface TeamOrchestrationSectionProps {
workspaceScope: string;
setWorkspaceScope: (v: string) => void;
memberRequestsEnabled: boolean;
setMemberRequestsEnabled: (v: boolean) => void;
memberRequestsAutoDispatch: boolean;
setMemberRequestsAutoDispatch: (v: boolean) => void;
blockerEscalationEnabled: boolean;
setBlockerEscalationEnabled: (v: boolean) => void;
followupInterval: number;
setFollowupInterval: (v: number) => void;
followupMaxReminders: number;
setFollowupMaxReminders: (v: number) => void;
}
const WORKSPACE_OPTIONS = [
{ value: "isolated", Icon: FolderLock, labelKey: "workspaceScopeIsolated", descKey: "workspaceScopeIsolatedDesc" },
{ value: "shared", Icon: FolderSync, labelKey: "workspaceScopeShared", descKey: "workspaceScopeSharedDesc" },
] as const;
/** Workspace scope, member requests, blocker escalation, and follow-up reminder settings. */
export function TeamOrchestrationSection({
workspaceScope, setWorkspaceScope,
memberRequestsEnabled, setMemberRequestsEnabled,
memberRequestsAutoDispatch, setMemberRequestsAutoDispatch,
blockerEscalationEnabled, setBlockerEscalationEnabled,
followupInterval, setFollowupInterval,
followupMaxReminders, setFollowupMaxReminders,
}: TeamOrchestrationSectionProps) {
const { t } = useTranslation("teams");
return (
<>
{/* Workspace Scope */}
<div className="space-y-4">
<h3 className="text-sm font-medium">{t("settings.workspace")}</h3>
<div className="rounded-lg border bg-gradient-to-r from-emerald-500/5 to-teal-500/5 p-4">
<div className="flex items-start gap-4">
<div className="rounded-lg bg-emerald-500/10 p-2.5 text-emerald-600 dark:text-emerald-400">
<FolderSync className="h-5 w-5" />
</div>
<div className="flex-1 space-y-3">
<div className="space-y-1">
<span className="text-sm font-semibold">{t("settings.workspaceScope")}</span>
<p className="text-xs text-muted-foreground leading-relaxed">{t("settings.workspaceScopeHint")}</p>
</div>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{WORKSPACE_OPTIONS.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => setWorkspaceScope(opt.value)}
className={
"flex items-start gap-3 rounded-lg border p-3 text-left transition-colors cursor-pointer " +
(workspaceScope === opt.value ? "border-primary bg-primary/5" : "border-border hover:border-primary/50")
}
>
<opt.Icon className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
<div>
<div className="text-sm font-medium">{t(`settings.${opt.labelKey}`)}</div>
<div className="mt-0.5 text-xs text-muted-foreground">{t(`settings.${opt.descKey}`)}</div>
</div>
</button>
))}
</div>
</div>
</div>
</div>
</div>
{/* Member Requests */}
<div className="space-y-4">
<h3 className="text-sm font-medium">{t("settings.memberRequests")}</h3>
<div className="rounded-lg border p-4 space-y-3">
<div className="flex items-center justify-between">
<div>
<span className="text-sm font-semibold">{t("settings.memberRequestsEnabled")}</span>
<p className="text-xs text-muted-foreground">{t("settings.memberRequestsEnabledDesc")}</p>
</div>
<Switch checked={memberRequestsEnabled} onCheckedChange={setMemberRequestsEnabled} />
</div>
{memberRequestsEnabled && (
<div className="flex items-center justify-between border-t pt-3">
<div>
<span className="text-sm font-semibold">{t("settings.memberRequestsAutoDispatch")}</span>
<p className="text-xs text-muted-foreground">{t("settings.memberRequestsAutoDispatchDesc")}</p>
</div>
<Switch checked={memberRequestsAutoDispatch} onCheckedChange={setMemberRequestsAutoDispatch} />
</div>
)}
</div>
</div>
{/* Blocker Escalation */}
<div className="space-y-4">
<h3 className="text-sm font-medium">{t("settings.blockerEscalation")}</h3>
<div className="rounded-lg border bg-gradient-to-r from-orange-500/5 to-red-500/5 p-4">
<div className="flex items-start gap-4">
<div className="rounded-lg bg-orange-500/10 p-2.5 text-orange-600 dark:text-orange-400">
<ShieldAlert className="h-5 w-5" />
</div>
<div className="flex-1">
<div className="flex items-center justify-between">
<div className="space-y-1">
<span className="text-sm font-semibold">{t("settings.blockerEscalationEnabled")}</span>
<p className="text-xs text-muted-foreground leading-relaxed">{t("settings.blockerEscalationHint")}</p>
</div>
<Switch checked={blockerEscalationEnabled} onCheckedChange={setBlockerEscalationEnabled} />
</div>
</div>
</div>
</div>
</div>
{/* Follow-up Reminders */}
<div className="space-y-4">
<h3 className="text-sm font-medium">{t("settings.followupReminders")}</h3>
<div className="rounded-lg border bg-gradient-to-r from-amber-500/5 to-yellow-500/5 p-4">
<div className="flex items-start gap-4">
<div className="rounded-lg bg-amber-500/10 p-2.5 text-amber-600 dark:text-amber-400">
<Clock className="h-5 w-5" />
</div>
<div className="flex-1 space-y-4">
<div className="space-y-1.5">
<label className="text-sm font-medium">{t("settings.followupInterval")}</label>
<p className="text-xs text-muted-foreground leading-relaxed">{t("settings.followupIntervalHint")}</p>
<input
type="number" min={1} max={1440}
value={followupInterval}
onChange={(e) => setFollowupInterval(Math.max(1, parseInt(e.target.value) || 30))}
className="w-24 rounded-md border bg-background px-3 py-1.5 text-base md:text-sm"
/>
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium">{t("settings.followupMaxReminders")}</label>
<p className="text-xs text-muted-foreground leading-relaxed">{t("settings.followupMaxRemindersHint")}</p>
<input
type="number" min={0} max={100}
value={followupMaxReminders}
onChange={(e) => setFollowupMaxReminders(Math.max(0, parseInt(e.target.value) || 0))}
className="w-24 rounded-md border bg-background px-3 py-1.5 text-base md:text-sm"
/>
</div>
</div>
</div>
</div>
</div>
</>
);
}
+33 -345
View File
@@ -1,14 +1,13 @@
import { useState, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Switch } from "@/components/ui/switch";
import { Combobox } from "@/components/ui/combobox";
import { X, Save, Bell, ShieldAlert, Clock, FolderLock, FolderSync, Zap, Bot, Loader2 } from "lucide-react";
import { Save, Loader2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import { CHANNEL_TYPES } from "@/constants/channels";
import { MultiUserPicker } from "@/components/shared/multi-user-picker";
import type { TeamData, TeamAccessSettings, TeamNotifyConfig, EscalationMode, EscalationAction } from "@/types/team";
import { useTeams } from "./hooks/use-teams";
import { TeamNotificationsSection } from "./team-notifications-section";
import { TeamAccessControlSection } from "./team-access-control-section";
import { TeamOrchestrationSection } from "./team-orchestration-section";
interface TeamSettingsTabProps {
teamId: string;
@@ -16,55 +15,10 @@ interface TeamSettingsTabProps {
onSaved: () => void;
}
/** Generic multi-select with static options (used for channel type selection). */
function MultiSelect({
options,
selected,
onChange,
placeholder,
}: {
options: { value: string; label?: string }[];
selected: string[];
onChange: (values: string[]) => void;
placeholder: string;
}) {
return (
<div className="space-y-2">
<Combobox
value=""
onChange={(val) => {
if (val && !selected.includes(val)) {
onChange([...selected, val]);
}
}}
options={options.filter((o) => !selected.includes(o.value))}
placeholder={placeholder}
/>
{selected.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{selected.map((id) => (
<Badge key={id} variant="secondary" className="gap-1 pr-1">
{options.find((o) => o.value === id)?.label ?? id}
<button
type="button"
onClick={() => onChange(selected.filter((s) => s !== id))}
className="ml-0.5 cursor-pointer rounded-full p-0.5 hover:bg-muted"
>
<X className="h-3 w-3" />
</button>
</Badge>
))}
</div>
)}
</div>
);
}
export function TeamSettingsTab({ teamId, team, onSaved }: TeamSettingsTabProps) {
const { t } = useTranslation("teams");
const { updateTeamSettings } = useTeams();
// Parse initial settings
const initial = (team.settings ?? {}) as TeamAccessSettings;
const [allowUserIds, setAllowUserIds] = useState<string[]>(initial.allow_user_ids ?? []);
const [denyUserIds, setDenyUserIds] = useState<string[]>(initial.deny_user_ids ?? []);
@@ -89,10 +43,8 @@ export function TeamSettingsTab({ teamId, team, onSaved }: TeamSettingsTabProps)
const [followupInterval, setFollowupInterval] = useState<number>(initial.followup_interval_minutes ?? 30);
const [followupMaxReminders, setFollowupMaxReminders] = useState<number>(initial.followup_max_reminders ?? 0);
const [workspaceScope, setWorkspaceScope] = useState<string>(initial.workspace_scope ?? "isolated");
const [saving, setSaving] = useState(false);
// Reset when team changes
useEffect(() => {
const s = (team.settings ?? {}) as TeamAccessSettings;
setAllowUserIds(s.allow_user_ids ?? []);
@@ -134,16 +86,13 @@ export function TeamSettingsTab({ teamId, team, onSaved }: TeamSettingsTabProps)
failed: notifyFailed,
slow_tool: notifySlowTool,
mode: notifyMode,
completed: notifyCompleted,
commented: notifyCommented,
new_task: notifyNewTask,
};
notifications.completed = notifyCompleted;
notifications.commented = notifyCommented;
notifications.new_task = notifyNewTask;
settings.notifications = notifications;
if (memberRequestsEnabled) {
settings.member_requests = {
enabled: true,
auto_dispatch: memberRequestsAutoDispatch,
};
settings.member_requests = { enabled: true, auto_dispatch: memberRequestsAutoDispatch };
}
if (escalationMode) {
settings.escalation_mode = escalationMode;
@@ -165,293 +114,33 @@ export function TeamSettingsTab({ teamId, team, onSaved }: TeamSettingsTabProps)
return (
<div className="space-y-6">
{/* Notifications */}
<div className="space-y-4">
<h3 className="text-sm font-medium">{t("settings.notifications")}</h3>
<div className="rounded-lg border bg-gradient-to-r from-blue-500/5 to-orange-500/5 p-4 space-y-3">
<div className="flex items-start gap-4">
<div className="rounded-lg bg-blue-500/10 p-2.5 text-blue-600 dark:text-blue-400">
<Bell className="h-5 w-5" />
</div>
<div className="flex-1 space-y-3">
<div className="flex items-center justify-between">
<div>
<span className="text-sm font-semibold">{t("settings.notifyDispatched")}</span>
<p className="text-xs text-muted-foreground">{t("settings.notifyDispatchedHint")}</p>
</div>
<Switch checked={notifyDispatched} onCheckedChange={setNotifyDispatched} />
</div>
<div className="flex items-center justify-between">
<div>
<span className="text-sm font-semibold">{t("settings.notifyProgress")}</span>
<p className="text-xs text-muted-foreground">{t("settings.notifyProgressHint")}</p>
</div>
<Switch checked={notifyProgress} onCheckedChange={setNotifyProgress} />
</div>
<div className="flex items-center justify-between">
<div>
<span className="text-sm font-semibold">{t("settings.notifyFailed")}</span>
<p className="text-xs text-muted-foreground">{t("settings.notifyFailedHint")}</p>
</div>
<Switch checked={notifyFailed} onCheckedChange={setNotifyFailed} />
</div>
<div className="flex items-center justify-between">
<div>
<span className="text-sm font-semibold">{t("settings.notifyCompleted")}</span>
</div>
<Switch checked={notifyCompleted} onCheckedChange={setNotifyCompleted} />
</div>
<div className="flex items-center justify-between">
<div>
<span className="text-sm font-semibold">{t("settings.notifyCommented")}</span>
</div>
<Switch checked={notifyCommented} onCheckedChange={setNotifyCommented} />
</div>
<div className="flex items-center justify-between">
<div>
<span className="text-sm font-semibold">{t("settings.notifyNewTask")}</span>
</div>
<Switch checked={notifyNewTask} onCheckedChange={setNotifyNewTask} />
</div>
<div className="flex items-center justify-between">
<div>
<span className="text-sm font-semibold">{t("settings.notifySlowTool")}</span>
<p className="text-xs text-muted-foreground">{t("settings.notifySlowToolHint")}</p>
</div>
<Switch checked={notifySlowTool} onCheckedChange={setNotifySlowTool} />
</div>
<div className="border-t pt-3 space-y-2">
<span className="text-sm font-semibold">{t("settings.notifyMode")}</span>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{([
{ value: "direct" as const, Icon: Zap, labelKey: "notifyModeDirect", descKey: "notifyModeDirectDesc" },
{ value: "leader" as const, Icon: Bot, labelKey: "notifyModeLeader", descKey: "notifyModeLeaderDesc" },
]).map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => setNotifyMode(opt.value)}
className={
"flex items-start gap-3 rounded-lg border p-3 text-left transition-colors cursor-pointer " +
(notifyMode === opt.value
? "border-primary bg-primary/5"
: "border-border hover:border-primary/50")
}
>
<opt.Icon className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
<div>
<div className="text-sm font-medium">{t(`settings.${opt.labelKey}`)}</div>
<div className="mt-0.5 text-xs text-muted-foreground">
{t(`settings.${opt.descKey}`)}
</div>
</div>
</button>
))}
</div>
{notifyMode === "leader" && (
<p className="text-xs text-amber-600 dark:text-amber-400">
⚠️ {t("settings.notifyModeLeaderWarning")}
</p>
)}
</div>
</div>
</div>
</div>
</div>
<TeamNotificationsSection
notifyDispatched={notifyDispatched} setNotifyDispatched={setNotifyDispatched}
notifyProgress={notifyProgress} setNotifyProgress={setNotifyProgress}
notifyFailed={notifyFailed} setNotifyFailed={setNotifyFailed}
notifyCompleted={notifyCompleted} setNotifyCompleted={setNotifyCompleted}
notifyCommented={notifyCommented} setNotifyCommented={setNotifyCommented}
notifyNewTask={notifyNewTask} setNotifyNewTask={setNotifyNewTask}
notifySlowTool={notifySlowTool} setNotifySlowTool={setNotifySlowTool}
notifyMode={notifyMode} setNotifyMode={setNotifyMode}
/>
{/* Workspace Scope */}
<div className="space-y-4">
<h3 className="text-sm font-medium">{t("settings.workspace")}</h3>
<div className="rounded-lg border bg-gradient-to-r from-emerald-500/5 to-teal-500/5 p-4">
<div className="flex items-start gap-4">
<div className="rounded-lg bg-emerald-500/10 p-2.5 text-emerald-600 dark:text-emerald-400">
<FolderSync className="h-5 w-5" />
</div>
<div className="flex-1 space-y-3">
<div className="space-y-1">
<span className="text-sm font-semibold">{t("settings.workspaceScope")}</span>
<p className="text-xs text-muted-foreground leading-relaxed">
{t("settings.workspaceScopeHint")}
</p>
</div>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{([
{ value: "isolated", Icon: FolderLock, labelKey: "workspaceScopeIsolated", descKey: "workspaceScopeIsolatedDesc" },
{ value: "shared", Icon: FolderSync, labelKey: "workspaceScopeShared", descKey: "workspaceScopeSharedDesc" },
] as const).map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => setWorkspaceScope(opt.value)}
className={
"flex items-start gap-3 rounded-lg border p-3 text-left transition-colors cursor-pointer " +
(workspaceScope === opt.value
? "border-primary bg-primary/5"
: "border-border hover:border-primary/50")
}
>
<opt.Icon className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
<div>
<div className="text-sm font-medium">{t(`settings.${opt.labelKey}`)}</div>
<div className="mt-0.5 text-xs text-muted-foreground">
{t(`settings.${opt.descKey}`)}
</div>
</div>
</button>
))}
</div>
</div>
</div>
</div>
</div>
<TeamOrchestrationSection
workspaceScope={workspaceScope} setWorkspaceScope={setWorkspaceScope}
memberRequestsEnabled={memberRequestsEnabled} setMemberRequestsEnabled={setMemberRequestsEnabled}
memberRequestsAutoDispatch={memberRequestsAutoDispatch} setMemberRequestsAutoDispatch={setMemberRequestsAutoDispatch}
blockerEscalationEnabled={blockerEscalationEnabled} setBlockerEscalationEnabled={setBlockerEscalationEnabled}
followupInterval={followupInterval} setFollowupInterval={setFollowupInterval}
followupMaxReminders={followupMaxReminders} setFollowupMaxReminders={setFollowupMaxReminders}
/>
{/* Member Requests */}
<div className="space-y-4">
<h3 className="text-sm font-medium">{t("settings.memberRequests")}</h3>
<div className="rounded-lg border p-4 space-y-3">
<div className="flex items-center justify-between">
<div>
<span className="text-sm font-semibold">{t("settings.memberRequestsEnabled")}</span>
<p className="text-xs text-muted-foreground">{t("settings.memberRequestsEnabledDesc")}</p>
</div>
<Switch checked={memberRequestsEnabled} onCheckedChange={setMemberRequestsEnabled} />
</div>
{memberRequestsEnabled && (
<div className="flex items-center justify-between border-t pt-3">
<div>
<span className="text-sm font-semibold">{t("settings.memberRequestsAutoDispatch")}</span>
<p className="text-xs text-muted-foreground">{t("settings.memberRequestsAutoDispatchDesc")}</p>
</div>
<Switch checked={memberRequestsAutoDispatch} onCheckedChange={setMemberRequestsAutoDispatch} />
</div>
)}
</div>
</div>
{/* Blocker Escalation */}
<div className="space-y-4">
<h3 className="text-sm font-medium">{t("settings.blockerEscalation")}</h3>
<div className="rounded-lg border bg-gradient-to-r from-orange-500/5 to-red-500/5 p-4">
<div className="flex items-start gap-4">
<div className="rounded-lg bg-orange-500/10 p-2.5 text-orange-600 dark:text-orange-400">
<ShieldAlert className="h-5 w-5" />
</div>
<div className="flex-1 space-y-3">
<div className="flex items-center justify-between">
<div className="space-y-1">
<span className="text-sm font-semibold">{t("settings.blockerEscalationEnabled")}</span>
<p className="text-xs text-muted-foreground leading-relaxed">
{t("settings.blockerEscalationHint")}
</p>
</div>
<Switch checked={blockerEscalationEnabled} onCheckedChange={setBlockerEscalationEnabled} />
</div>
</div>
</div>
</div>
</div>
{/* Follow-up Reminders */}
<div className="space-y-4">
<h3 className="text-sm font-medium">{t("settings.followupReminders")}</h3>
<div className="rounded-lg border bg-gradient-to-r from-amber-500/5 to-yellow-500/5 p-4">
<div className="flex items-start gap-4">
<div className="rounded-lg bg-amber-500/10 p-2.5 text-amber-600 dark:text-amber-400">
<Clock className="h-5 w-5" />
</div>
<div className="flex-1 space-y-4">
<div className="space-y-1.5">
<label className="text-sm font-medium">{t("settings.followupInterval")}</label>
<p className="text-xs text-muted-foreground leading-relaxed">
{t("settings.followupIntervalHint")}
</p>
<input
type="number"
min={1}
max={1440}
value={followupInterval}
onChange={(e) => setFollowupInterval(Math.max(1, parseInt(e.target.value) || 30))}
className="w-24 rounded-md border bg-background px-3 py-1.5 text-base md:text-sm"
/>
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium">{t("settings.followupMaxReminders")}</label>
<p className="text-xs text-muted-foreground leading-relaxed">
{t("settings.followupMaxRemindersHint")}
</p>
<input
type="number"
min={0}
max={100}
value={followupMaxReminders}
onChange={(e) => setFollowupMaxReminders(Math.max(0, parseInt(e.target.value) || 0))}
className="w-24 rounded-md border bg-background px-3 py-1.5 text-base md:text-sm"
/>
</div>
</div>
</div>
</div>
</div>
{/* User Access Control */}
<div className="space-y-4">
<h3 className="text-sm font-medium">{t("settings.userAccessControl")}</h3>
<div className="space-y-3 rounded-lg border p-4">
<div className="space-y-1.5">
<label className="text-sm font-medium">{t("settings.allowedUsers")}</label>
<p className="text-xs text-muted-foreground">
{t("settings.allowedUsersHint")}
</p>
<MultiUserPicker
value={allowUserIds}
onChange={setAllowUserIds}
placeholder={t("settings.searchUsers")}
/>
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium">{t("settings.deniedUsers")}</label>
<p className="text-xs text-muted-foreground">
{t("settings.deniedUsersHint")}
</p>
<MultiUserPicker
value={denyUserIds}
onChange={setDenyUserIds}
placeholder={t("settings.searchUsers")}
/>
</div>
</div>
</div>
{/* Channel Restrictions */}
<div className="space-y-4">
<h3 className="text-sm font-medium">{t("settings.channelRestrictions")}</h3>
<div className="space-y-3 rounded-lg border p-4">
<div className="space-y-1.5">
<label className="text-sm font-medium">{t("settings.allowedChannels")}</label>
<p className="text-xs text-muted-foreground">
{t("settings.allowedChannelsHint")}
</p>
<MultiSelect
options={channelOptions}
selected={allowChannels}
onChange={setAllowChannels}
placeholder={t("settings.selectChannel")}
/>
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium">{t("settings.deniedChannels")}</label>
<p className="text-xs text-muted-foreground">
{t("settings.deniedChannelsHint")}
</p>
<MultiSelect
options={channelOptions}
selected={denyChannels}
onChange={setDenyChannels}
placeholder={t("settings.selectChannel")}
/>
</div>
</div>
</div>
<TeamAccessControlSection
allowUserIds={allowUserIds} setAllowUserIds={setAllowUserIds}
denyUserIds={denyUserIds} setDenyUserIds={setDenyUserIds}
allowChannels={allowChannels} setAllowChannels={setAllowChannels}
denyChannels={denyChannels} setDenyChannels={setDenyChannels}
channelOptions={channelOptions}
/>
{/* Save button */}
<div className="flex items-center gap-3">
@@ -460,7 +149,6 @@ export function TeamSettingsTab({ teamId, team, onSaved }: TeamSettingsTabProps)
{saving ? t("settings.saving") : t("settings.save")}
</Button>
</div>
</div>
);
}
+71 -417
View File
@@ -1,53 +1,18 @@
import { useState, useEffect, useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Badge } from "@/components/ui/badge";
import { ChevronRight, ChevronDown, Copy, Check, Download, CircleCheck, CircleX, Loader, CircleMinus, Square } from "lucide-react";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Copy, Check, Download, Square } from "lucide-react";
import { useClipboard } from "@/hooks/use-clipboard";
import { useHttp } from "@/hooks/use-ws";
import { useWsEvent } from "@/hooks/use-ws-event";
import { Events } from "@/api/protocol";
import hljs from "highlight.js/lib/core";
import json from "highlight.js/lib/languages/json";
import { formatDate, formatDuration, formatTokens, computeDurationMs } from "@/lib/format";
import { useUiStore } from "@/stores/use-ui-store";
import { SpanTreeNode, StatusBadge, buildSpanTree } from "./trace-span-tree-node";
import { TracePreviewBlock } from "./trace-preview-block";
import type { TraceData, SpanData } from "./hooks/use-traces";
import type { AgentEventPayload } from "@/types/chat";
hljs.registerLanguage("json", json);
interface SpanNode {
span: SpanData;
children: SpanNode[];
}
function buildSpanTree(spans: SpanData[]): SpanNode[] {
const map = new Map<string, SpanNode>();
const roots: SpanNode[] = [];
// Create nodes
for (const span of spans) {
map.set(span.id, { span, children: [] });
}
// Link parent → children
for (const span of spans) {
const node = map.get(span.id)!;
if (span.parent_span_id && map.has(span.parent_span_id)) {
map.get(span.parent_span_id)!.children.push(node);
} else {
roots.push(node);
}
}
return roots;
}
interface TraceDetailDialogProps {
traceId: string;
onClose: () => void;
@@ -72,65 +37,38 @@ export function TraceDetailDialog({ traceId, onClose, getTrace, onNavigateTrace,
const blob = await http.downloadBlob(`/v1/traces/${traceId}/export`);
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `trace-${traceId.slice(0, 8)}.json.gz`;
a.click();
a.href = url; a.download = `trace-${traceId.slice(0, 8)}.json.gz`; a.click();
URL.revokeObjectURL(url);
} catch {
// silently fail — user sees no download
} finally {
setExporting(false);
}
} catch { /* silently fail */ } finally { setExporting(false); }
}, [http, traceId]);
const fetchTrace = useCallback(() => {
getTrace(traceId).then((result) => {
if (result) {
setTrace(result.trace);
setSpans(result.spans ?? []);
}
if (result) { setTrace(result.trace); setSpans(result.spans ?? []); }
});
}, [traceId, getTrace]);
useEffect(() => {
setLoading(true);
getTrace(traceId)
.then((result) => {
if (result) {
setTrace(result.trace);
setSpans(result.spans ?? []);
}
})
.then((result) => { if (result) { setTrace(result.trace); setSpans(result.spans ?? []); } })
.finally(() => setLoading(false));
}, [traceId, getTrace]);
// Auto-refetch when trace aggregates update (spans flushed every ~5s)
useWsEvent(
Events.TRACE_UPDATED,
useCallback(
(payload: unknown) => {
const data = payload as { trace_ids?: string[] };
if (data?.trace_ids?.includes(traceId)) {
fetchTrace();
}
},
[traceId, fetchTrace],
),
);
// Auto-refetch when trace aggregates update
useWsEvent(Events.TRACE_UPDATED, useCallback(
(payload: unknown) => { const data = payload as { trace_ids?: string[] }; if (data?.trace_ids?.includes(traceId)) fetchTrace(); },
[traceId, fetchTrace],
));
// Also refetch on run completion (final status + output)
useWsEvent(
Events.AGENT,
useCallback(
(payload: unknown) => {
const event = payload as AgentEventPayload;
if (event?.type === "run.completed" || event?.type === "run.failed" || event?.type === "run.cancelled") {
fetchTrace();
}
},
[fetchTrace],
),
);
// Refetch on run completion
useWsEvent(Events.AGENT, useCallback(
(payload: unknown) => {
const event = payload as AgentEventPayload;
if (event?.type === "run.completed" || event?.type === "run.failed" || event?.type === "run.cancelled") fetchTrace();
},
[fetchTrace],
));
const spanTree = useMemo(() => buildSpanTree(spans), [spans]);
@@ -140,366 +78,82 @@ export function TraceDetailDialog({ traceId, onClose, getTrace, onNavigateTrace,
<DialogHeader>
<DialogTitle className="flex items-center gap-2 pr-8">
{t("detail.title")}
<button
type="button"
onClick={() => copy(traceId)}
className="ml-auto flex items-center gap-1 rounded px-2 py-1 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
<button type="button" onClick={() => copy(traceId)} className="ml-auto flex items-center gap-1 rounded px-2 py-1 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground">
{copied ? <Check className="h-3.5 w-3.5 text-green-500" /> : <Copy className="h-3.5 w-3.5" />}
{t("detail.copyTraceId")}
</button>
<button
type="button"
onClick={handleExport}
disabled={exporting || !trace}
className="flex items-center gap-1 rounded px-2 py-1 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:opacity-50"
>
{exporting ? (
<div className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-muted-foreground border-t-transparent" />
) : (
<Download className="h-3.5 w-3.5" />
)}
<button type="button" onClick={handleExport} disabled={exporting || !trace} className="flex items-center gap-1 rounded px-2 py-1 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:opacity-50">
{exporting ? <div className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-muted-foreground border-t-transparent" /> : <Download className="h-3.5 w-3.5" />}
{t("detail.export")}
</button>
{trace && (trace.status === "running") && onAbortRun && (
<button
type="button"
onClick={(e) => onAbortRun(trace, e)}
className="flex cursor-pointer items-center gap-1 rounded-md bg-destructive px-2 py-1 text-xs text-destructive-foreground transition-colors hover:bg-destructive/90"
>
<Square className="h-3.5 w-3.5" />
{t("detail.stopRun")}
{trace && trace.status === "running" && onAbortRun && (
<button type="button" onClick={(e) => onAbortRun(trace, e)} className="flex cursor-pointer items-center gap-1 rounded-md bg-destructive px-2 py-1 text-xs text-destructive-foreground transition-colors hover:bg-destructive/90">
<Square className="h-3.5 w-3.5" />{t("detail.stopRun")}
</button>
)}
</DialogTitle>
</DialogHeader>
<div className="overflow-y-auto min-h-0 -mx-4 px-4 sm:-mx-6 sm:px-6">
{loading && !trace ? (
<div className="flex items-center justify-center py-12">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-muted-foreground border-t-transparent" />
</div>
) : !trace ? (
<p className="py-8 text-center text-sm text-muted-foreground">{t("detail.notFound")}</p>
) : (
<div className="space-y-4">
{/* Trace summary */}
<div className="grid grid-cols-2 gap-3 text-sm sm:grid-cols-4">
<div>
<span className="text-muted-foreground">{t("detail.name")}</span>{" "}
<span className="font-medium">{trace.name || t("unnamed")}</span>
</div>
<div>
<span className="text-muted-foreground">{t("detail.status")}</span>{" "}
<StatusBadge status={trace.status} />
</div>
<div>
<span className="text-muted-foreground">{t("detail.duration")}</span>{" "}
{formatDuration(trace.duration_ms || computeDurationMs(trace.start_time, trace.end_time))}
</div>
<div>
<span className="text-muted-foreground">{t("detail.channel")}</span>{" "}
{trace.channel || "—"}
</div>
<div>
<span className="text-muted-foreground">{t("detail.tokens")}</span>{" "}
{formatTokens(trace.total_input_tokens)} in / {formatTokens(trace.total_output_tokens)} out
{((trace.metadata?.total_cache_read_tokens ?? 0) > 0 || (trace.metadata?.total_cache_creation_tokens ?? 0) > 0) && (
<span className="ml-1 text-xs">
{(trace.metadata?.total_cache_read_tokens ?? 0) > 0 && (
<span className="text-green-400">{formatTokens(trace.metadata!.total_cache_read_tokens!)} {t("span.cached")}</span>
)}
</span>
)}
</div>
<div>
<span className="text-muted-foreground">{t("detail.spans")}</span>{" "}
{trace.span_count} ({trace.llm_call_count} {t("detail.llmCalls")}, {trace.tool_call_count} {t("detail.toolCalls")})
</div>
<div>
<span className="text-muted-foreground">{t("detail.started")}</span>{" "}
{formatDate(trace.start_time, tz)}
</div>
<div>
<span className="text-muted-foreground">{t("detail.createdAt")}</span>{" "}
{formatDate(trace.created_at, tz)}
</div>
{trace.parent_trace_id && (
{loading && !trace ? (
<div className="flex items-center justify-center py-12"><div className="h-6 w-6 animate-spin rounded-full border-2 border-muted-foreground border-t-transparent" /></div>
) : !trace ? (
<p className="py-8 text-center text-sm text-muted-foreground">{t("detail.notFound")}</p>
) : (
<div className="space-y-4">
<TraceSummaryGrid trace={trace} tz={tz} onNavigateTrace={onNavigateTrace} />
{trace.input_preview && <TracePreviewBlock label={t("detail.input")} content={trace.input_preview} />}
{trace.output_preview && <TracePreviewBlock label={t("detail.output")} content={trace.output_preview} />}
{trace.error && (
<div className="rounded-md border border-red-400/30 bg-red-500/10 p-3">
<p className="break-all text-sm text-red-300">{trace.error}</p>
</div>
)}
{spans.length > 0 && (
<div>
<span className="text-muted-foreground">{t("detail.delegatedFrom")}</span>{" "}
<button
type="button"
className="cursor-pointer font-mono text-xs text-primary hover:underline"
onClick={() => onNavigateTrace?.(trace.parent_trace_id!)}
>
{trace.parent_trace_id.slice(0, 8)}...
</button>
<h4 className="mb-2 text-sm font-medium">{t("detail.spansCount", { count: spans.length })}</h4>
<div className="space-y-1">
{spanTree.map((node) => <SpanTreeNode key={node.span.id} node={node} depth={0} />)}
</div>
</div>
)}
</div>
{trace.input_preview && (
<PreviewBlock label={t("detail.input")} content={trace.input_preview} />
)}
{trace.output_preview && (
<PreviewBlock label={t("detail.output")} content={trace.output_preview} />
)}
{trace.error && (
<div className="rounded-md border border-red-400/30 bg-red-500/10 p-3">
<p className="break-all text-sm text-red-300">{trace.error}</p>
</div>
)}
{/* Span tree */}
{spans.length > 0 && (
<div>
<h4 className="mb-2 text-sm font-medium">{t("detail.spansCount", { count: spans.length })}</h4>
<div className="space-y-1">
{spanTree.map((node) => (
<SpanTreeNode key={node.span.id} node={node} depth={0} />
))}
</div>
</div>
)}
</div>
)}
)}
</div>
</DialogContent>
</Dialog>
);
}
function SpanTreeNode({ node, depth }: { node: SpanNode; depth: number }) {
/** Trace metadata summary grid */
function TraceSummaryGrid({ trace, tz, onNavigateTrace }: { trace: TraceData; tz: string; onNavigateTrace?: (id: string) => void }) {
const { t } = useTranslation("traces");
const tz = useUiStore((s) => s.timezone);
const [expanded, setExpanded] = useState(depth === 0);
const [detailOpen, setDetailOpen] = useState(false);
const { span, children } = node;
const hasChildren = children.length > 0;
return (
<div>
<div
className="mt-1.5 min-w-0 rounded-md border text-sm"
style={{ marginLeft: depth * 16 }}
>
<div className="flex w-full items-center gap-1 px-2 py-2">
{/* Tree toggle */}
{hasChildren ? (
<button
type="button"
className="flex h-5 w-5 shrink-0 cursor-pointer items-center justify-center rounded hover:bg-muted"
onClick={() => setExpanded(!expanded)}
>
{expanded ? (
<ChevronDown className="h-3.5 w-3.5" />
) : (
<ChevronRight className="h-3.5 w-3.5" />
)}
</button>
) : (
<span className="w-5 shrink-0" />
)}
{/* Span info row - clickable for detail */}
<button
type="button"
className="flex flex-1 cursor-pointer items-center gap-2 text-left hover:opacity-80"
onClick={() => setDetailOpen(!detailOpen)}
>
<Badge variant="outline" className="shrink-0 text-xs">
{span.span_type}
</Badge>
<span className="flex-1 truncate font-medium">
{span.name || span.tool_name || "span"}
</span>
{(span.input_tokens > 0 || span.output_tokens > 0) && (
<span className="hidden shrink-0 text-xs text-muted-foreground sm:inline">
{formatTokens(span.input_tokens)}/{formatTokens(span.output_tokens)}
{(span.metadata?.cache_read_tokens ?? 0) > 0 && (
<span className="ml-1 text-green-400">
({formatTokens(span.metadata!.cache_read_tokens!)} {t("span.cached")})
</span>
)}
{(span.metadata?.thinking_tokens ?? 0) > 0 && (
<span className="ml-1 text-orange-400">
({formatTokens(span.metadata!.thinking_tokens!)} {t("span.thinking")})
</span>
)}
</span>
)}
{span.created_at && (
<Badge variant="outline" className="hidden shrink-0 text-xs text-muted-foreground lg:inline-flex">
{formatDate(span.created_at, tz)}
</Badge>
)}
<Badge variant="outline" className="shrink-0 text-xs text-muted-foreground">
{formatDuration(span.duration_ms || computeDurationMs(span.start_time, span.end_time))}
</Badge>
<StatusBadge status={span.status} />
</button>
</div>
{/* Expanded detail panel */}
{detailOpen && (
<div className="max-h-[50vh] space-y-2 overflow-y-auto border-t px-3 py-2">
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs">
{span.start_time && (
<div>
<span className="text-muted-foreground">{t("span.startTime")}</span> {formatDate(span.start_time, tz)}
</div>
)}
{span.end_time && (
<div>
<span className="text-muted-foreground">{t("span.endTime")}</span> {formatDate(span.end_time, tz)}
</div>
)}
{span.model && (
<div>
<span className="text-muted-foreground">{t("span.model")}</span> {span.provider}/{span.model}
</div>
)}
</div>
{(span.input_tokens > 0 || span.output_tokens > 0) && (
<div className="text-xs">
<span className="text-muted-foreground">{t("span.tokens")}</span>{" "}
{formatTokens(span.input_tokens)} in / {formatTokens(span.output_tokens)} out
{((span.metadata?.cache_creation_tokens ?? 0) > 0 || (span.metadata?.cache_read_tokens ?? 0) > 0) && (
<span className="ml-2 text-muted-foreground">
(cache:
{(span.metadata?.cache_read_tokens ?? 0) > 0 && (
<span className="ml-1 text-green-400">{formatTokens(span.metadata!.cache_read_tokens!)} {t("span.cacheRead")}</span>
)}
{(span.metadata?.cache_creation_tokens ?? 0) > 0 && (
<span className="ml-1 text-yellow-400">{formatTokens(span.metadata!.cache_creation_tokens!)} {t("span.cacheWrite")}</span>
)}
)
</span>
)}
{(span.metadata?.thinking_tokens ?? 0) > 0 && (
<span className="ml-2 text-muted-foreground">
(<span className="text-orange-400">{formatTokens(span.metadata!.thinking_tokens!)} {t("span.thinking")}</span>)
</span>
)}
</div>
)}
{span.metadata?.reasoning && (
<div className="text-xs text-muted-foreground">
<span>{t("span.reasoning")}</span>{" "}
{span.metadata.reasoning.requested_effort ? (
<span>
{t("span.requested")} {span.metadata.reasoning.requested_effort}
</span>
) : null}
{span.metadata.reasoning.source ? (
<span className="ml-2">
{t("span.source")} {t(`span.sourceValue.${span.metadata.reasoning.source}`)}
</span>
) : null}
{span.metadata.reasoning.effective_effort ? (
<span className="ml-2">
{t("span.effective")} {span.metadata.reasoning.effective_effort}
</span>
) : null}
{span.metadata.reasoning.fallback ? (
<span className="ml-2">
{t("span.fallback")} {span.metadata.reasoning.fallback}
</span>
) : null}
{span.metadata.reasoning.used_provider_default ? (
<span className="ml-2">{t("span.modelDefault")}</span>
) : null}
{span.metadata.reasoning.reason ? (
<div className="mt-1">{span.metadata.reasoning.reason}</div>
) : null}
{span.metadata.reasoning.supported_levels?.length ? (
<div className="mt-1">
{t("span.supportedLevels")} {span.metadata.reasoning.supported_levels.join(", ")}
</div>
) : null}
</div>
)}
{span.input_preview && (
<PreviewBlock label={t("span.input")} content={span.input_preview} />
)}
{span.output_preview && (
<PreviewBlock label={t("span.output")} content={span.output_preview} />
)}
{span.error && (
<p className="break-all text-xs text-red-300">{span.error}</p>
)}
</div>
<div className="grid grid-cols-2 gap-3 text-sm sm:grid-cols-4">
<div><span className="text-muted-foreground">{t("detail.name")}</span> <span className="font-medium">{trace.name || t("unnamed")}</span></div>
<div><span className="text-muted-foreground">{t("detail.status")}</span> <StatusBadge status={trace.status} /></div>
<div><span className="text-muted-foreground">{t("detail.duration")}</span> {formatDuration(trace.duration_ms || computeDurationMs(trace.start_time, trace.end_time))}</div>
<div><span className="text-muted-foreground">{t("detail.channel")}</span> {trace.channel || "—"}</div>
<div>
<span className="text-muted-foreground">{t("detail.tokens")}</span>{" "}
{formatTokens(trace.total_input_tokens)} in / {formatTokens(trace.total_output_tokens)} out
{((trace.metadata?.total_cache_read_tokens ?? 0) > 0) && (
<span className="ml-1 text-xs text-green-400">{formatTokens(trace.metadata!.total_cache_read_tokens!)} {t("span.cached")}</span>
)}
</div>
{/* Render children when tree is expanded */}
{expanded && children.map((child) => (
<SpanTreeNode key={child.span.id} node={child} depth={depth + 1} />
))}
</div>
);
}
/** Try to pretty-print JSON; returns { text, isJson }. */
function formatPreview(text: string): { text: string; isJson: boolean } {
const trimmed = text.trim();
if ((trimmed.startsWith("{") && trimmed.endsWith("}")) || (trimmed.startsWith("[") && trimmed.endsWith("]"))) {
try {
const pretty = JSON.stringify(JSON.parse(trimmed), null, 2);
return { text: pretty, isJson: true };
} catch {
// not valid JSON
}
}
return { text, isJson: false };
}
function PreviewBlock({ label, content }: { label: string; content: string }) {
const { t } = useTranslation("traces");
const { copied, copy } = useClipboard();
const { text: formatted, isJson } = useMemo(() => formatPreview(content), [content]);
const highlightedHtml = useMemo(() => {
if (!isJson) return null;
return hljs.highlight(formatted, { language: "json" }).value;
}, [formatted, isJson]);
return (
<div className="relative rounded-md border p-3">
<p className="mb-1 text-xs font-medium text-muted-foreground">{label}</p>
<button
type="button"
onClick={() => copy(content)}
className="absolute right-2 top-2 flex items-center gap-1 rounded px-1.5 py-0.5 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
{copied ? <Check className="h-3 w-3 text-green-500" /> : <Copy className="h-3 w-3" />}
{t("detail.copy")}
</button>
{highlightedHtml ? (
<pre
className="hljs mt-1 max-h-[20vh] overflow-y-auto whitespace-pre-wrap break-all text-xs sm:max-h-[40vh]"
dangerouslySetInnerHTML={{ __html: highlightedHtml }}
/>
) : (
<pre className="mt-1 max-h-[20vh] overflow-y-auto whitespace-pre-wrap break-all text-xs sm:max-h-[40vh]">{formatted}</pre>
<div><span className="text-muted-foreground">{t("detail.spans")}</span> {trace.span_count} ({trace.llm_call_count} {t("detail.llmCalls")}, {trace.tool_call_count} {t("detail.toolCalls")})</div>
<div><span className="text-muted-foreground">{t("detail.started")}</span> {formatDate(trace.start_time, tz)}</div>
<div><span className="text-muted-foreground">{t("detail.createdAt")}</span> {formatDate(trace.created_at, tz)}</div>
{trace.parent_trace_id && (
<div>
<span className="text-muted-foreground">{t("detail.delegatedFrom")}</span>{" "}
<button type="button" className="cursor-pointer font-mono text-xs text-primary hover:underline" onClick={() => onNavigateTrace?.(trace.parent_trace_id!)}>
{trace.parent_trace_id.slice(0, 8)}...
</button>
</div>
)}
</div>
);
}
function StatusBadge({ status }: { status: string }) {
const isOk = status === "ok" || status === "success" || status === "completed";
const isError = status === "error" || status === "failed";
const isRunning = status === "running" || status === "pending";
const variant = isOk ? "success" : isError ? "destructive" : isRunning ? "info" : "secondary";
const Icon = isOk ? CircleCheck : isError ? CircleX : isRunning ? Loader : CircleMinus;
return (
<Badge variant={variant} className="text-xs">
<Icon className={"h-3 w-3 sm:hidden" + (isRunning ? " animate-spin" : "")} />
<span className="hidden sm:inline">{status || "unknown"}</span>
</Badge>
);
}
@@ -0,0 +1,48 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Copy, Check } from "lucide-react";
import { useClipboard } from "@/hooks/use-clipboard";
import hljs from "highlight.js/lib/core";
import json from "highlight.js/lib/languages/json";
hljs.registerLanguage("json", json);
/** Try to pretty-print JSON; returns { text, isJson }. */
function formatPreview(text: string): { text: string; isJson: boolean } {
const trimmed = text.trim();
if ((trimmed.startsWith("{") && trimmed.endsWith("}")) || (trimmed.startsWith("[") && trimmed.endsWith("]"))) {
try {
return { text: JSON.stringify(JSON.parse(trimmed), null, 2), isJson: true };
} catch { /* not valid JSON */ }
}
return { text, isJson: false };
}
export function TracePreviewBlock({ label, content }: { label: string; content: string }) {
const { t } = useTranslation("traces");
const { copied, copy } = useClipboard();
const { text: formatted, isJson } = useMemo(() => formatPreview(content), [content]);
const highlightedHtml = useMemo(() => {
if (!isJson) return null;
return hljs.highlight(formatted, { language: "json" }).value;
}, [formatted, isJson]);
return (
<div className="relative rounded-md border p-3">
<p className="mb-1 text-xs font-medium text-muted-foreground">{label}</p>
<button
type="button"
onClick={() => copy(content)}
className="absolute right-2 top-2 flex items-center gap-1 rounded px-1.5 py-0.5 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
{copied ? <Check className="h-3 w-3 text-green-500" /> : <Copy className="h-3 w-3" />}
{t("detail.copy")}
</button>
{highlightedHtml ? (
<pre className="hljs mt-1 max-h-[20vh] overflow-y-auto whitespace-pre-wrap break-all text-xs sm:max-h-[40vh]" dangerouslySetInnerHTML={{ __html: highlightedHtml }} />
) : (
<pre className="mt-1 max-h-[20vh] overflow-y-auto whitespace-pre-wrap break-all text-xs sm:max-h-[40vh]">{formatted}</pre>
)}
</div>
);
}
@@ -0,0 +1,138 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Badge } from "@/components/ui/badge";
import { ChevronRight, ChevronDown, CircleCheck, CircleX, Loader, CircleMinus } from "lucide-react";
import { formatDate, formatDuration, formatTokens, computeDurationMs } from "@/lib/format";
import { useUiStore } from "@/stores/use-ui-store";
import { TracePreviewBlock } from "./trace-preview-block";
import type { SpanData } from "./hooks/use-traces";
export interface SpanNode {
span: SpanData;
children: SpanNode[];
}
/** Build a parent→children tree from flat span list */
export function buildSpanTree(spans: SpanData[]): SpanNode[] {
const map = new Map<string, SpanNode>();
const roots: SpanNode[] = [];
for (const span of spans) map.set(span.id, { span, children: [] });
for (const span of spans) {
const node = map.get(span.id)!;
if (span.parent_span_id && map.has(span.parent_span_id)) {
map.get(span.parent_span_id)!.children.push(node);
} else {
roots.push(node);
}
}
return roots;
}
export function StatusBadge({ status }: { status: string }) {
const isOk = status === "ok" || status === "success" || status === "completed";
const isError = status === "error" || status === "failed";
const isRunning = status === "running" || status === "pending";
const variant = isOk ? "success" : isError ? "destructive" : isRunning ? "info" : "secondary";
const Icon = isOk ? CircleCheck : isError ? CircleX : isRunning ? Loader : CircleMinus;
return (
<Badge variant={variant} className="text-xs">
<Icon className={"h-3 w-3 sm:hidden" + (isRunning ? " animate-spin" : "")} />
<span className="hidden sm:inline">{status || "unknown"}</span>
</Badge>
);
}
export function SpanTreeNode({ node, depth }: { node: SpanNode; depth: number }) {
const { t } = useTranslation("traces");
const tz = useUiStore((s) => s.timezone);
const [expanded, setExpanded] = useState(depth === 0);
const [detailOpen, setDetailOpen] = useState(false);
const { span, children } = node;
const hasChildren = children.length > 0;
return (
<div>
<div className="mt-1.5 min-w-0 rounded-md border text-sm" style={{ marginLeft: depth * 16 }}>
<div className="flex w-full items-center gap-1 px-2 py-2">
{hasChildren ? (
<button type="button" className="flex h-5 w-5 shrink-0 cursor-pointer items-center justify-center rounded hover:bg-muted" onClick={() => setExpanded(!expanded)}>
{expanded ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
</button>
) : (
<span className="w-5 shrink-0" />
)}
<button type="button" className="flex flex-1 cursor-pointer items-center gap-2 text-left hover:opacity-80" onClick={() => setDetailOpen(!detailOpen)}>
<Badge variant="outline" className="shrink-0 text-xs">{span.span_type}</Badge>
<span className="flex-1 truncate font-medium">{span.name || span.tool_name || "span"}</span>
{(span.input_tokens > 0 || span.output_tokens > 0) && (
<span className="hidden shrink-0 text-xs text-muted-foreground sm:inline">
{formatTokens(span.input_tokens)}/{formatTokens(span.output_tokens)}
{(span.metadata?.cache_read_tokens ?? 0) > 0 && <span className="ml-1 text-green-400">({formatTokens(span.metadata!.cache_read_tokens!)} {t("span.cached")})</span>}
{(span.metadata?.thinking_tokens ?? 0) > 0 && <span className="ml-1 text-orange-400">({formatTokens(span.metadata!.thinking_tokens!)} {t("span.thinking")})</span>}
</span>
)}
{span.created_at && <Badge variant="outline" className="hidden shrink-0 text-xs text-muted-foreground lg:inline-flex">{formatDate(span.created_at, tz)}</Badge>}
<Badge variant="outline" className="shrink-0 text-xs text-muted-foreground">{formatDuration(span.duration_ms || computeDurationMs(span.start_time, span.end_time))}</Badge>
<StatusBadge status={span.status} />
</button>
</div>
{detailOpen && <SpanDetailPanel span={span} />}
</div>
{expanded && children.map((child) => (
<SpanTreeNode key={child.span.id} node={child} depth={depth + 1} />
))}
</div>
);
}
/** Expanded detail panel for a single span */
function SpanDetailPanel({ span }: { span: SpanData }) {
const { t } = useTranslation("traces");
const tz = useUiStore((s) => s.timezone);
const reasoning = span.metadata?.reasoning;
return (
<div className="max-h-[50vh] space-y-2 overflow-y-auto border-t px-3 py-2">
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs">
{span.start_time && <div><span className="text-muted-foreground">{t("span.startTime")}</span> {formatDate(span.start_time, tz)}</div>}
{span.end_time && <div><span className="text-muted-foreground">{t("span.endTime")}</span> {formatDate(span.end_time, tz)}</div>}
{span.model && <div><span className="text-muted-foreground">{t("span.model")}</span> {span.provider}/{span.model}</div>}
</div>
{(span.input_tokens > 0 || span.output_tokens > 0) && (
<div className="text-xs">
<span className="text-muted-foreground">{t("span.tokens")}</span>{" "}
{formatTokens(span.input_tokens)} in / {formatTokens(span.output_tokens)} out
{((span.metadata?.cache_creation_tokens ?? 0) > 0 || (span.metadata?.cache_read_tokens ?? 0) > 0) && (
<span className="ml-2 text-muted-foreground">
(cache:
{(span.metadata?.cache_read_tokens ?? 0) > 0 && <span className="ml-1 text-green-400">{formatTokens(span.metadata!.cache_read_tokens!)} {t("span.cacheRead")}</span>}
{(span.metadata?.cache_creation_tokens ?? 0) > 0 && <span className="ml-1 text-yellow-400">{formatTokens(span.metadata!.cache_creation_tokens!)} {t("span.cacheWrite")}</span>}
)
</span>
)}
{(span.metadata?.thinking_tokens ?? 0) > 0 && (
<span className="ml-2 text-muted-foreground">(<span className="text-orange-400">{formatTokens(span.metadata!.thinking_tokens!)} {t("span.thinking")}</span>)</span>
)}
</div>
)}
{reasoning && (
<div className="text-xs text-muted-foreground">
<span>{t("span.reasoning")}</span>{" "}
{reasoning.requested_effort ? <span>{t("span.requested")} {reasoning.requested_effort}</span> : null}
{reasoning.source ? <span className="ml-2">{t("span.source")} {t(`span.sourceValue.${reasoning.source}`)}</span> : null}
{reasoning.effective_effort ? <span className="ml-2">{t("span.effective")} {reasoning.effective_effort}</span> : null}
{reasoning.fallback ? <span className="ml-2">{t("span.fallback")} {reasoning.fallback}</span> : null}
{reasoning.used_provider_default ? <span className="ml-2">{t("span.modelDefault")}</span> : null}
{reasoning.reason ? <div className="mt-1">{reasoning.reason}</div> : null}
{reasoning.supported_levels?.length ? <div className="mt-1">{t("span.supportedLevels")} {reasoning.supported_levels.join(", ")}</div> : null}
</div>
)}
{span.input_preview && <TracePreviewBlock label={t("span.input")} content={span.input_preview} />}
{span.output_preview && <TracePreviewBlock label={t("span.output")} content={span.output_preview} />}
{span.error && <p className="break-all text-xs text-red-300">{span.error}</p>}
</div>
);
}