feat(ui): improve kanban UX, fix dialog scroll, remove delegation page

- Kanban: reorder columns (blocked after pending), show blocked-by info
  on cards, clickable blocker links in task detail, framer-motion card
  animation between columns
- Dialogs: standardize scroll pattern across all modals — header fixed,
  scrollbar flush with outer edge via negative margin trick
- Remove delegation page, types, events, i18n, routes, and all references
- Fix activity_logs NULL jsonb scan error (COALESCE)
- Board header: show text labels on action buttons (desktop)
This commit is contained in:
viettranx committed 2026-03-16 22:37:33 +07:00
1 parent b0bd4d6198
commit 6075d588e3
51 files changed
+114 -1117

No files matched your search

+1 -1
View File
@@ -38,7 +38,7 @@ func (s *PGActivityStore) List(ctx context.Context, opts store.ActivityListOpts)
args = append(args, limit, opts.Offset)
query := fmt.Sprintf(
`SELECT id, actor_type, actor_id, action, COALESCE(entity_type,''), COALESCE(entity_id,''), details, COALESCE(ip_address,''), created_at
`SELECT id, actor_type, actor_id, action, COALESCE(entity_type,''), COALESCE(entity_id,''), COALESCE(details, 'null'::jsonb), COALESCE(ip_address,''), created_at
FROM activity_logs %s ORDER BY created_at DESC LIMIT $%d OFFSET $%d`,
where, len(args)-1, len(args),
)
-19
View File
@@ -150,10 +150,6 @@ export const Methods = {
TEAMS_WORKSPACE_READ: "teams.workspace.read",
TEAMS_WORKSPACE_DELETE: "teams.workspace.delete",
// Delegation history
DELEGATIONS_LIST: "delegations.list",
DELEGATIONS_GET: "delegations.get",
// Phase 3+ - NICE TO HAVE
LOGS_TAIL: "logs.tail",
} as const;
@@ -177,17 +173,6 @@ export const Events = {
VOICEWAKE_CHANGED: "voicewake.changed",
CONNECT_CHALLENGE: "connect.challenge",
TALK_MODE: "talk.mode",
HANDOFF: "handoff",
// Delegation lifecycle
DELEGATION_STARTED: "delegation.started",
DELEGATION_COMPLETED: "delegation.completed",
DELEGATION_FAILED: "delegation.failed",
DELEGATION_CANCELLED: "delegation.cancelled",
DELEGATION_PROGRESS: "delegation.progress",
DELEGATION_ACCUMULATED: "delegation.accumulated",
DELEGATION_ANNOUNCE: "delegation.announce",
DELEGATION_QUALITY_GATE_RETRY: "delegation.quality_gate.retry",
// Team tasks
TEAM_TASK_CREATED: "team.task.created",
@@ -234,10 +219,6 @@ export const Events = {
/** All event names relevant to team debug view */
export const TEAM_RELATED_EVENTS: Set<string> = new Set([
Events.DELEGATION_STARTED, Events.DELEGATION_COMPLETED,
Events.DELEGATION_FAILED, Events.DELEGATION_CANCELLED,
Events.DELEGATION_PROGRESS, Events.DELEGATION_ACCUMULATED,
Events.DELEGATION_ANNOUNCE, Events.DELEGATION_QUALITY_GATE_RETRY,
Events.TEAM_TASK_CREATED, Events.TEAM_TASK_CLAIMED,
Events.TEAM_TASK_COMPLETED, Events.TEAM_TASK_CANCELLED,
Events.TEAM_TASK_REVIEWED, Events.TEAM_TASK_APPROVED,
-2
View File
@@ -17,7 +17,6 @@ import {
Plug,
Volume2,
Cpu,
ArrowRightLeft,
ClipboardList,
HardDrive,
Inbox,
@@ -106,7 +105,6 @@ export function Sidebar({ collapsed, onNavItemClick }: SidebarProps) {
<SidebarGroup label={t("groups.monitoring")} collapsed={collapsed}>
<SidebarItem to={ROUTES.TRACES} icon={Activity} label={t("nav.traces")} collapsed={collapsed} />
<SidebarItem to={ROUTES.EVENTS} icon={Radar} label={t("nav.realtimeEvents")} collapsed={collapsed} />
<SidebarItem to={ROUTES.DELEGATIONS} icon={ArrowRightLeft} label={t("nav.delegations")} collapsed={collapsed} />
<SidebarItem to={ROUTES.ACTIVITY} icon={ClipboardList} label={t("nav.activity")} collapsed={collapsed} />
<SidebarItem to={ROUTES.LOGS} icon={Terminal} label={t("nav.logs")} collapsed={collapsed} />
</SidebarGroup>
+4 -7
View File
@@ -18,7 +18,6 @@ import enChannels from "./locales/en/channels.json";
import enProviders from "./locales/en/providers.json";
import enTraces from "./locales/en/traces.json";
import enEvents from "./locales/en/events.json";
import enDelegations from "./locales/en/delegations.json";
import enUsage from "./locales/en/usage.json";
import enApprovals from "./locales/en/approvals.json";
import enNodes from "./locales/en/nodes.json";
@@ -52,7 +51,6 @@ import viChannels from "./locales/vi/channels.json";
import viProviders from "./locales/vi/providers.json";
import viTraces from "./locales/vi/traces.json";
import viEvents from "./locales/vi/events.json";
import viDelegations from "./locales/vi/delegations.json";
import viUsage from "./locales/vi/usage.json";
import viApprovals from "./locales/vi/approvals.json";
import viNodes from "./locales/vi/nodes.json";
@@ -86,7 +84,6 @@ import zhChannels from "./locales/zh/channels.json";
import zhProviders from "./locales/zh/providers.json";
import zhTraces from "./locales/zh/traces.json";
import zhEvents from "./locales/zh/events.json";
import zhDelegations from "./locales/zh/delegations.json";
import zhUsage from "./locales/zh/usage.json";
import zhApprovals from "./locales/zh/approvals.json";
import zhNodes from "./locales/zh/nodes.json";
@@ -117,7 +114,7 @@ function getInitialLanguage(): string {
const ns = [
"common", "sidebar", "topbar", "login", "overview", "chat",
"agents", "teams", "sessions", "skills", "cron", "config",
"channels", "providers", "traces", "events", "delegations",
"channels", "providers", "traces", "events",
"usage", "approvals", "nodes", "logs", "tools", "mcp", "tts",
"setup", "memory", "storage", "pending-messages", "contacts", "activity", "api-keys",
"cli-credentials",
@@ -130,7 +127,7 @@ i18n.use(initReactI18next).init({
overview: enOverview, chat: enChat, agents: enAgents, teams: enTeams,
sessions: enSessions, skills: enSkills, cron: enCron, config: enConfig,
channels: enChannels, providers: enProviders, traces: enTraces,
events: enEvents, delegations: enDelegations, usage: enUsage,
events: enEvents, usage: enUsage,
approvals: enApprovals, nodes: enNodes, logs: enLogs, tools: enTools,
mcp: enMcp, tts: enTts, setup: enSetup, memory: enMemory, storage: enStorage,
"pending-messages": enPendingMessages,
@@ -142,7 +139,7 @@ i18n.use(initReactI18next).init({
overview: viOverview, chat: viChat, agents: viAgents, teams: viTeams,
sessions: viSessions, skills: viSkills, cron: viCron, config: viConfig,
channels: viChannels, providers: viProviders, traces: viTraces,
events: viEvents, delegations: viDelegations, usage: viUsage,
events: viEvents, usage: viUsage,
approvals: viApprovals, nodes: viNodes, logs: viLogs, tools: viTools,
mcp: viMcp, tts: viTts, setup: viSetup, memory: viMemory, storage: viStorage,
"pending-messages": viPendingMessages,
@@ -154,7 +151,7 @@ i18n.use(initReactI18next).init({
overview: zhOverview, chat: zhChat, agents: zhAgents, teams: zhTeams,
sessions: zhSessions, skills: zhSkills, cron: zhCron, config: zhConfig,
channels: zhChannels, providers: zhProviders, traces: zhTraces,
events: zhEvents, delegations: zhDelegations, usage: zhUsage,
events: zhEvents, usage: zhUsage,
approvals: zhApprovals, nodes: zhNodes, logs: zhLogs, tools: zhTools,
mcp: zhMcp, tts: zhTts, setup: zhSetup, memory: zhMemory, storage: zhStorage,
"pending-messages": zhPendingMessages,
@@ -1,41 +0,0 @@
{
"title": "Delegations",
"description": "Agent delegation history and results",
"sourceFilterPlaceholder": "Source agent...",
"targetFilterPlaceholder": "Target agent...",
"filter": "Filter",
"emptyTitle": "No delegations",
"emptyDescription": "No delegation records found. Delegations are recorded when agents delegate tasks to other agents.",
"columns": {
"sourceTarget": "Source / Target",
"task": "Task",
"status": "Status",
"mode": "Mode",
"duration": "Duration",
"time": "Time"
},
"statusFilter": {
"all": "All",
"pending": "Pending",
"running": "Running",
"completed": "Completed",
"failed": "Failed",
"cancelled": "Cancelled"
},
"detail": {
"title": "Delegation Detail",
"notFound": "Delegation not found.",
"source": "Source:",
"target": "Target:",
"status": "Status:",
"mode": "Mode:",
"duration": "Duration:",
"iterations": "Iterations:",
"created": "Created:",
"completed": "Completed:",
"trace": "Trace:",
"task": "Task",
"result": "Result",
"error": "Error"
}
}
-1
View File
@@ -11,7 +11,6 @@
"eventCountPlural": "{{count}} events",
"categories": {
"all": "All",
"delegation": "Delegation",
"task": "Task",
"message": "Message",
"agent": "Agent",
-1
View File
@@ -26,7 +26,6 @@
"knowledgeGraph": "Knowledge Graph",
"traces": "Traces",
"realtimeEvents": "Realtime Events",
"delegations": "Delegations",
"usage": "Usage",
"logs": "Logs",
"storage": "Storage",
@@ -1,41 +0,0 @@
{
"title": "Ủy quyền",
"description": "Lịch sử và kết quả ủy quyền agent",
"sourceFilterPlaceholder": "Agent nguồn...",
"targetFilterPlaceholder": "Agent đích...",
"filter": "Lọc",
"emptyTitle": "Không có ủy quyền",
"emptyDescription": "Không tìm thấy bản ghi ủy quyền. Ủy quyền được ghi lại khi agent ủy thác nhiệm vụ cho agent khác.",
"columns": {
"sourceTarget": "Nguồn / Đích",
"task": "Nhiệm vụ",
"status": "Trạng thái",
"mode": "Chế độ",
"duration": "Thời lượng",
"time": "Thời gian"
},
"statusFilter": {
"all": "Tất cả",
"pending": "Đang chờ",
"running": "Đang chạy",
"completed": "Hoàn thành",
"failed": "Thất bại",
"cancelled": "Đã hủy"
},
"detail": {
"title": "Chi tiết ủy quyền",
"notFound": "Không tìm thấy ủy quyền.",
"source": "Nguồn:",
"target": "Đích:",
"status": "Trạng thái:",
"mode": "Chế độ:",
"duration": "Thời lượng:",
"iterations": "Số lần lặp:",
"created": "Đã tạo:",
"completed": "Hoàn thành:",
"trace": "Theo dõi:",
"task": "Nhiệm vụ",
"result": "Kết quả",
"error": "Lỗi"
}
}
-1
View File
@@ -11,7 +11,6 @@
"eventCountPlural": "{{count}} sự kiện",
"categories": {
"all": "Tất cả",
"delegation": "Ủy quyền",
"task": "Nhiệm vụ",
"message": "Tin nhắn",
"agent": "Agent",
-1
View File
@@ -26,7 +26,6 @@
"knowledgeGraph": "Knowledge Graph",
"traces": "Theo dõi",
"realtimeEvents": "Sự kiện thời gian thực",
"delegations": "Ủy quyền",
"usage": "Sử dụng",
"logs": "Nhật ký",
"storage": "Lưu trữ",
@@ -1,41 +0,0 @@
{
"title": "委托",
"description": "Agent委托历史和结果",
"sourceFilterPlaceholder": "源Agent...",
"targetFilterPlaceholder": "目标Agent...",
"filter": "筛选",
"emptyTitle": "暂无委托",
"emptyDescription": "暂无委托记录。当Agent将任务委托给其他Agent时将自动记录。",
"columns": {
"sourceTarget": "源 / 目标",
"task": "任务",
"status": "状态",
"mode": "模式",
"duration": "耗时",
"time": "时间"
},
"statusFilter": {
"all": "全部",
"pending": "等待中",
"running": "运行中",
"completed": "已完成",
"failed": "已失败",
"cancelled": "已取消"
},
"detail": {
"title": "委托详情",
"notFound": "未找到委托。",
"source": "源:",
"target": "目标:",
"status": "状态:",
"mode": "模式:",
"duration": "耗时:",
"iterations": "迭代次数:",
"created": "创建时间:",
"completed": "完成时间:",
"trace": "追踪:",
"task": "任务",
"result": "结果",
"error": "错误"
}
}
-1
View File
@@ -11,7 +11,6 @@
"eventCountPlural": "{{count}} 个事件",
"categories": {
"all": "全部",
"delegation": "委托",
"task": "任务",
"message": "消息",
"agent": "Agent",
-1
View File
@@ -19,7 +19,6 @@
"config": "配置",
"cron": "定时任务",
"customTools": "自定义工具",
"delegations": "委托",
"logs": "日志",
"mcpServers": "MCP 服务器",
"memory": "记忆",
-1
View File
@@ -15,7 +15,6 @@ export const ROUTES = {
TRACES: "/traces",
TRACE_DETAIL: "/traces/:id",
EVENTS: "/events",
DELEGATIONS: "/delegations",
USAGE: "/usage",
CHANNELS: "/channels",
CHANNEL_DETAIL: "/channels/:id",
-4
View File
@@ -62,10 +62,6 @@ export const queryKeys = {
all: ["usage"] as const,
records: (params: Record<string, unknown>) => ["usage", "records", params] as const,
},
delegations: {
all: ["delegations"] as const,
list: (params: Record<string, unknown>) => ["delegations", params] as const,
},
teams: {
all: ["teams"] as const,
detail: (id: string) => ["teams", id] as const,
@@ -122,7 +122,7 @@ export function AgentCreateDialog({ open, onOpenChange, onCreate }: AgentCreateD
<DialogHeader>
<DialogTitle>{t("create.title")}</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4 px-0.5 -mx-0.5 overflow-y-auto min-h-0">
<div className="space-y-4 py-4 -mx-4 px-4 sm:-mx-6 sm:px-6 overflow-y-auto min-h-0">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="displayName">{t("create.displayName")}</Label>
@@ -260,7 +260,7 @@ export function ChannelInstanceFormDialog({
{/* === FORM STEP === */}
{step === "form" && (
<>
<div className="grid gap-4 py-2 px-0.5 -mx-0.5 overflow-y-auto min-h-0">
<div className="grid gap-4 py-2 -mx-4 px-4 sm:-mx-6 sm:px-6 overflow-y-auto min-h-0">
<div className="grid gap-1.5">
<Label htmlFor="ci-name">{t("form.key")}</Label>
<Input id="ci-name" value={name} onChange={(e) => setName(slugify(e.target.value))} placeholder={t("form.keyPlaceholder")} disabled={!!instance} />
@@ -380,7 +380,7 @@ export function ChannelInstanceFormDialog({
{/* === CONFIG STEP (rendered by registered component) === */}
{step === "config" && createdInstanceId && ConfigStep && (
<>
<div className="py-2 px-0.5 -mx-0.5 overflow-y-auto min-h-0">
<div className="py-2 -mx-4 px-4 sm:-mx-6 sm:px-6 overflow-y-auto min-h-0">
<ConfigStep
instanceId={createdInstanceId}
authCompleted={authCompleted}
+1 -1
View File
@@ -72,7 +72,7 @@ export function CronFormDialog({ open, onOpenChange, onSubmit }: CronFormDialogP
<DialogHeader>
<DialogTitle>{t("create.title")}</DialogTitle>
</DialogHeader>
<div className="space-y-4 px-0.5 -mx-0.5 overflow-y-auto min-h-0">
<div className="space-y-4 -mx-4 px-4 sm:-mx-6 sm:px-6 overflow-y-auto min-h-0">
<div className="space-y-2">
<Label>{t("create.name")}</Label>
<Input value={name} onChange={(e) => setName(slugify(e.target.value))} placeholder={t("create.namePlaceholder")} />
+34 -32
View File
@@ -28,43 +28,45 @@ export function CronRunLogDialog({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[70vh] overflow-y-auto sm:max-w-2xl">
<DialogContent className="max-h-[70vh] flex flex-col sm:max-w-2xl">
<DialogHeader>
<DialogTitle>{t("runLog.title", { name: jobName })}</DialogTitle>
</DialogHeader>
{loading && entries.length === 0 ? (
<div className="flex items-center justify-center py-8">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-muted-foreground border-t-transparent" />
</div>
) : entries.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">
{t("runLog.noHistory")}
</p>
) : (
<div className="space-y-2">
{entries.map((entry: CronRunLogEntry, i: number) => (
<div key={i} className="rounded-md border p-3 text-sm">
<div className="flex items-center justify-between">
<span className="text-muted-foreground">
{formatDate(new Date(entry.ts))}
</span>
<Badge
variant={entry.status === "ok" || entry.status === "success" ? "success" : "destructive"}
>
{entry.status || "unknown"}
</Badge>
<div className="overflow-y-auto min-h-0 -mx-4 px-4 sm:-mx-6 sm:px-6">
{loading && entries.length === 0 ? (
<div className="flex items-center justify-center py-8">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-muted-foreground border-t-transparent" />
</div>
) : entries.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">
{t("runLog.noHistory")}
</p>
) : (
<div className="space-y-2">
{entries.map((entry: CronRunLogEntry, i: number) => (
<div key={i} className="rounded-md border p-3 text-sm">
<div className="flex items-center justify-between">
<span className="text-muted-foreground">
{formatDate(new Date(entry.ts))}
</span>
<Badge
variant={entry.status === "ok" || entry.status === "success" ? "success" : "destructive"}
>
{entry.status || "unknown"}
</Badge>
</div>
{entry.summary && (
<p className="mt-1 text-muted-foreground">{entry.summary}</p>
)}
{entry.error && (
<p className="mt-1 text-destructive">{entry.error}</p>
)}
</div>
{entry.summary && (
<p className="mt-1 text-muted-foreground">{entry.summary}</p>
)}
{entry.error && (
<p className="mt-1 text-destructive">{entry.error}</p>
)}
</div>
))}
</div>
)}
))}
</div>
)}
</div>
</DialogContent>
</Dialog>
);
@@ -97,7 +97,7 @@ export function CustomToolFormDialog({ open, onOpenChange, tool, onSubmit }: Cus
<DialogTitle>{tool ? t("custom.form.editTitle") : t("custom.form.createTitle")}</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-2 px-0.5 -mx-0.5 overflow-y-auto min-h-0">
<div className="grid gap-4 py-2 -mx-4 px-4 sm:-mx-6 sm:px-6 overflow-y-auto min-h-0">
<div className="grid gap-1.5">
<Label htmlFor="ct-name">{t("custom.form.name")}</Label>
<Input id="ct-name" value={name} onChange={(e) => setName(slugify(e.target.value))} placeholder={t("custom.form.namePlaceholder")} />
@@ -1,145 +0,0 @@
import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Badge } from "@/components/ui/badge";
import { formatDate, formatDuration } from "@/lib/format";
import { TraceDetailDialog } from "@/pages/traces/trace-detail-dialog";
import type { DelegationHistoryRecord } from "@/types/delegation";
import type { TraceData, SpanData } from "@/types/trace";
interface DelegationDetailDialogProps {
delegationId: string;
onClose: () => void;
getDelegation: (id: string) => Promise<DelegationHistoryRecord | null>;
getTrace: (traceId: string) => Promise<{ trace: TraceData; spans: SpanData[] } | null>;
}
export function DelegationDetailDialog({ delegationId, onClose, getDelegation, getTrace }: DelegationDetailDialogProps) {
const { t } = useTranslation("delegations");
const [record, setRecord] = useState<DelegationHistoryRecord | null>(null);
const [loading, setLoading] = useState(true);
const [viewingTraceId, setViewingTraceId] = useState<string | null>(null);
useEffect(() => {
setLoading(true);
getDelegation(delegationId)
.then((r) => setRecord(r))
.finally(() => setLoading(false));
}, [delegationId, getDelegation]);
const statusVariant =
record?.status === "completed"
? "success"
: record?.status === "failed"
? "destructive"
: record?.status === "running" || record?.status === "pending"
? "info"
: "secondary";
return (
<Dialog open onOpenChange={() => onClose()}>
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-4xl">
<DialogHeader>
<DialogTitle>{t("detail.title")}</DialogTitle>
</DialogHeader>
{loading && !record ? (
<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>
) : !record ? (
<p className="py-8 text-center text-sm text-muted-foreground">{t("detail.notFound")}</p>
) : (
<div className="space-y-4">
{/* Summary grid */}
<div className="grid grid-cols-2 gap-3 text-sm sm:grid-cols-4">
<div>
<span className="text-muted-foreground">{t("detail.source")}</span>{" "}
<span className="font-medium">{record.source_agent_key || record.source_agent_id.slice(0, 8)}</span>
</div>
<div>
<span className="text-muted-foreground">{t("detail.target")}</span>{" "}
<span className="font-medium">{record.target_agent_key || record.target_agent_id.slice(0, 8)}</span>
</div>
<div>
<span className="text-muted-foreground">{t("detail.status")}</span>{" "}
<Badge variant={statusVariant} className="text-xs">{record.status}</Badge>
</div>
<div>
<span className="text-muted-foreground">{t("detail.mode")}</span>{" "}
<Badge variant="outline" className="text-xs">{record.mode}</Badge>
</div>
<div>
<span className="text-muted-foreground">{t("detail.duration")}</span>{" "}
{formatDuration(record.duration_ms)}
</div>
<div>
<span className="text-muted-foreground">{t("detail.iterations")}</span>{" "}
{record.iterations}
</div>
<div>
<span className="text-muted-foreground">{t("detail.created")}</span>{" "}
{formatDate(record.created_at)}
</div>
<div>
<span className="text-muted-foreground">{t("detail.completed")}</span>{" "}
{record.completed_at ? formatDate(record.completed_at) : "—"}
</div>
</div>
{record.trace_id && (
<div className="text-sm">
<span className="text-muted-foreground">{t("detail.trace")}</span>{" "}
<button
type="button"
onClick={() => setViewingTraceId(record.trace_id!)}
className="cursor-pointer font-mono text-xs text-primary hover:underline"
>
{record.trace_id.slice(0, 12)}...
</button>
</div>
)}
{/* Task */}
<div className="rounded-md border p-3">
<p className="mb-1 text-xs font-medium text-muted-foreground">{t("detail.task")}</p>
<pre className="whitespace-pre-wrap break-words text-sm">{record.task}</pre>
</div>
{/* Result */}
{record.result && (
<div className="rounded-md border p-3">
<p className="mb-1 text-xs font-medium text-muted-foreground">{t("detail.result")}</p>
<pre className="max-h-[50vh] overflow-y-auto whitespace-pre-wrap break-words text-sm">
{record.result}
</pre>
</div>
)}
{/* Error */}
{record.error && (
<div className="rounded-md border border-red-400/30 bg-red-500/10 p-3">
<p className="mb-1 text-xs font-medium text-muted-foreground">{t("detail.error")}</p>
<p className="break-all text-sm text-red-300">{record.error}</p>
</div>
)}
</div>
)}
</DialogContent>
{viewingTraceId && (
<TraceDetailDialog
traceId={viewingTraceId}
onClose={() => setViewingTraceId(null)}
getTrace={getTrace}
onNavigateTrace={setViewingTraceId}
/>
)}
</Dialog>
);
}
@@ -1,229 +0,0 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { ArrowRightLeft, RefreshCw, Search } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { PageHeader } from "@/components/shared/page-header";
import { EmptyState } from "@/components/shared/empty-state";
import { Pagination } from "@/components/shared/pagination";
import { TableSkeleton } from "@/components/shared/loading-skeleton";
import { useWsEvent } from "@/hooks/use-ws-event";
import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
import { Events } from "@/api/protocol";
import { formatDate, formatDuration } from "@/lib/format";
import { useDelegations } from "./hooks/use-delegations";
import { useTraces } from "@/pages/traces/hooks/use-traces";
import { DelegationDetailDialog } from "./delegation-detail-dialog";
import { useMinLoading } from "@/hooks/use-min-loading";
import { useDeferredLoading } from "@/hooks/use-deferred-loading";
import type { AgentEventPayload } from "@/types/chat";
import type { DelegationHistoryRecord } from "@/types/delegation";
export function DelegationsPage() {
const { t } = useTranslation("delegations");
const { t: tc } = useTranslation("common");
const { delegations, total, loading, load, getDelegation } = useDelegations();
const { getTrace } = useTraces();
const spinning = useMinLoading(loading);
const showSkeleton = useDeferredLoading(loading && delegations.length === 0);
const [sourceFilter, setSourceFilter] = useState("");
const [targetFilter, setTargetFilter] = useState("");
const [statusFilter, setStatusFilter] = useState("all");
const [selectedId, setSelectedId] = useState<string | null>(null);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const sourceRef = useRef(sourceFilter);
sourceRef.current = sourceFilter;
const targetRef = useRef(targetFilter);
targetRef.current = targetFilter;
const statusRef = useRef(statusFilter);
statusRef.current = statusFilter;
const pageRef = useRef(page);
pageRef.current = page;
const pageSizeRef = useRef(pageSize);
pageSizeRef.current = pageSize;
const buildFilters = useCallback(() => ({
source_agent_id: sourceRef.current || undefined,
target_agent_id: targetRef.current || undefined,
status: statusRef.current !== "all" ? statusRef.current : undefined,
limit: pageSizeRef.current,
offset: (pageRef.current - 1) * pageSizeRef.current,
}), []);
useEffect(() => {
load({ limit: pageSize, offset: (page - 1) * pageSize });
}, [load, page, pageSize]);
const handleRefresh = () => load(buildFilters());
const debouncedRefresh = useDebouncedCallback(() => load(buildFilters()), 3000);
const handleAgentEvent = useCallback(
(payload: unknown) => {
const event = payload as AgentEventPayload;
if (!event) return;
if (event.type === "run.completed" || event.type === "run.failed") {
debouncedRefresh();
}
},
[debouncedRefresh],
);
useWsEvent(Events.AGENT, handleAgentEvent);
const handleFilterSubmit = (e: React.FormEvent) => {
e.preventDefault();
setPage(1);
handleRefresh();
};
return (
<div className="p-4 sm:p-6">
<PageHeader
title={t("title")}
description={t("description")}
actions={
<Button variant="outline" size="sm" onClick={handleRefresh} disabled={spinning} className="gap-1">
<RefreshCw className={"h-3.5 w-3.5" + (spinning ? " animate-spin" : "")} /> {tc("refresh")}
</Button>
}
/>
<form onSubmit={handleFilterSubmit} className="mt-4 flex flex-wrap gap-2">
<div className="relative max-w-[200px] flex-1">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={sourceFilter}
onChange={(e) => setSourceFilter(e.target.value)}
placeholder={t("sourceFilterPlaceholder")}
className="pl-9"
/>
</div>
<div className="relative max-w-[200px] flex-1">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={targetFilter}
onChange={(e) => setTargetFilter(e.target.value)}
placeholder={t("targetFilterPlaceholder")}
className="pl-9"
/>
</div>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t("statusFilter.all")}</SelectItem>
<SelectItem value="pending">{t("statusFilter.pending")}</SelectItem>
<SelectItem value="running">{t("statusFilter.running")}</SelectItem>
<SelectItem value="completed">{t("statusFilter.completed")}</SelectItem>
<SelectItem value="failed">{t("statusFilter.failed")}</SelectItem>
<SelectItem value="cancelled">{t("statusFilter.cancelled")}</SelectItem>
</SelectContent>
</Select>
<Button type="submit" variant="outline" size="sm">
{t("filter")}
</Button>
</form>
<div className="mt-4">
{showSkeleton ? (
<TableSkeleton rows={8} />
) : delegations.length === 0 ? (
<EmptyState
icon={ArrowRightLeft}
title={t("emptyTitle")}
description={t("emptyDescription")}
/>
) : (
<div className="overflow-x-auto rounded-md border">
<table className="w-full min-w-[600px] text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="px-4 py-3 text-left font-medium">{t("columns.sourceTarget")}</th>
<th className="px-4 py-3 text-left font-medium">{t("columns.task")}</th>
<th className="px-4 py-3 text-left font-medium">{t("columns.status")}</th>
<th className="px-4 py-3 text-left font-medium">{t("columns.mode")}</th>
<th className="px-4 py-3 text-left font-medium">{t("columns.duration")}</th>
<th className="px-4 py-3 text-left font-medium">{t("columns.time")}</th>
</tr>
</thead>
<tbody>
{delegations.map((d: DelegationHistoryRecord) => (
<tr
key={d.id}
className="cursor-pointer border-b last:border-0 hover:bg-muted/30"
onClick={() => setSelectedId(d.id)}
>
<td className="px-4 py-3">
<span className="font-medium">{d.source_agent_key || d.source_agent_id.slice(0, 8)}</span>
<span className="mx-1 text-muted-foreground">&rarr;</span>
<span className="font-medium">{d.target_agent_key || d.target_agent_id.slice(0, 8)}</span>
</td>
<td className="max-w-[300px] truncate px-4 py-3 text-muted-foreground">
{d.task}
</td>
<td className="px-4 py-3">
<StatusBadge status={d.status} />
</td>
<td className="px-4 py-3">
<Badge variant="outline" className="text-xs">{d.mode}</Badge>
</td>
<td className="px-4 py-3 text-muted-foreground">
{formatDuration(d.duration_ms)}
</td>
<td className="px-4 py-3 text-muted-foreground">
{formatDate(d.created_at)}
</td>
</tr>
))}
</tbody>
</table>
<Pagination
page={page}
pageSize={pageSize}
total={total}
totalPages={totalPages}
onPageChange={setPage}
onPageSizeChange={(size) => { setPageSize(size); setPage(1); }}
/>
</div>
)}
</div>
{selectedId && (
<DelegationDetailDialog
delegationId={selectedId}
onClose={() => setSelectedId(null)}
getDelegation={getDelegation}
getTrace={getTrace}
/>
)}
</div>
);
}
function StatusBadge({ status }: { status: string }) {
const variant =
status === "completed"
? "success"
: status === "failed"
? "destructive"
: status === "running" || status === "pending"
? "info"
: "secondary";
return <Badge variant={variant} className="text-xs">{status || "unknown"}</Badge>;
}
@@ -1,48 +0,0 @@
import { useState, useCallback } from "react";
import { useHttp } from "@/hooks/use-ws";
import type { DelegationHistoryRecord, DelegationListFilters } from "@/types/delegation";
export function useDelegations() {
const http = useHttp();
const [delegations, setDelegations] = useState<DelegationHistoryRecord[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const load = useCallback(
async (filters: DelegationListFilters = {}) => {
setLoading(true);
try {
const params: Record<string, string> = {};
if (filters.source_agent_id) params.source_agent_id = filters.source_agent_id;
if (filters.target_agent_id) params.target_agent_id = filters.target_agent_id;
if (filters.team_id) params.team_id = filters.team_id;
if (filters.user_id) params.user_id = filters.user_id;
if (filters.status) params.status = filters.status;
if (filters.limit) params.limit = String(filters.limit);
if (filters.offset !== undefined) params.offset = String(filters.offset);
const res = await http.get<{ records: DelegationHistoryRecord[]; total?: number }>("/v1/delegations", params);
setDelegations(res.records ?? []);
setTotal(res.total ?? 0);
} catch {
// ignore
} finally {
setLoading(false);
}
},
[http],
);
const getDelegation = useCallback(
async (id: string): Promise<DelegationHistoryRecord | null> => {
try {
return await http.get<DelegationHistoryRecord>(`/v1/delegations/${id}`);
} catch {
return null;
}
},
[http],
);
return { delegations, total, loading, load, getDelegation };
}
@@ -1,154 +0,0 @@
import { Badge } from "@/components/ui/badge";
import { formatDuration } from "@/lib/format";
import type { TeamEventEntry } from "@/stores/use-team-event-store";
import type {
DelegationEventPayload,
DelegationProgressPayload,
DelegationAccumulatedPayload,
DelegationAnnouncePayload,
QualityGateRetryPayload,
} from "@/types/team-events";
interface Props {
entry: TeamEventEntry;
resolveAgent: (keyOrId: string | undefined) => string;
}
export function DelegationEventCard({ entry, resolveAgent }: Props) {
switch (entry.event) {
case "delegation.started":
case "delegation.completed":
case "delegation.failed":
case "delegation.cancelled":
return <DelegationLifecycleCard entry={entry} resolveAgent={resolveAgent} />;
case "delegation.progress":
return <DelegationProgressCard payload={entry.payload as DelegationProgressPayload} resolveAgent={resolveAgent} />;
case "delegation.accumulated":
return <DelegationAccumulatedCard payload={entry.payload as DelegationAccumulatedPayload} resolveAgent={resolveAgent} />;
case "delegation.announce":
return <DelegationAnnounceCard payload={entry.payload as DelegationAnnouncePayload} resolveAgent={resolveAgent} />;
case "delegation.quality_gate.retry":
return <QualityGateRetryCard payload={entry.payload as QualityGateRetryPayload} resolveAgent={resolveAgent} />;
default:
return <pre className="overflow-x-auto text-xs">{JSON.stringify(entry.payload, null, 2)}</pre>;
}
}
function DelegationLifecycleCard({ entry, resolveAgent }: Props) {
const p = entry.payload as DelegationEventPayload;
const source = p.source_display_name || resolveAgent(p.source_agent_key);
const target = p.target_display_name || resolveAgent(p.target_agent_key);
return (
<div className="space-y-1 text-sm">
<div className="flex min-w-0 flex-wrap items-center gap-x-1 gap-y-0.5">
<span className="truncate font-medium">{source}</span>
<span className="shrink-0 text-muted-foreground">&rarr;</span>
<span className="truncate font-medium">{target}</span>
{p.mode && (
<Badge variant="outline" className="shrink-0 text-xs">
{p.mode}
</Badge>
)}
</div>
{p.task && <p className="break-words text-xs text-muted-foreground line-clamp-2">{p.task}</p>}
<div className="flex flex-wrap items-center gap-1.5 text-xs text-muted-foreground">
{p.delegation_id && (
<span className="rounded bg-muted px-1.5 py-0.5 font-mono">
deleg: {p.delegation_id.slice(0, 8)}
</span>
)}
{p.elapsed_ms != null && p.elapsed_ms > 0 && (
<span className="rounded bg-muted px-1.5 py-0.5">{formatDuration(p.elapsed_ms)}</span>
)}
{p.error && <span className="break-words text-destructive">{p.error}</span>}
</div>
</div>
);
}
type ResolverProp = { resolveAgent: (keyOrId: string | undefined) => string };
function DelegationProgressCard({ payload: p, resolveAgent }: { payload: DelegationProgressPayload } & ResolverProp) {
return (
<div className="space-y-1 text-sm">
<p className="truncate text-xs text-muted-foreground">
{resolveAgent(p.source_agent_key)} has {p.active_delegations.length} active delegation(s)
</p>
<div className="space-y-0.5">
{p.active_delegations.map((d) => (
<div key={d.delegation_id} className="flex min-w-0 items-center gap-2 text-xs">
<span className="truncate font-medium">{d.target_display_name || resolveAgent(d.target_agent_key)}</span>
<span className="shrink-0 text-muted-foreground">{formatDuration(d.elapsed_ms)}</span>
</div>
))}
</div>
</div>
);
}
function DelegationAccumulatedCard({ payload: p, resolveAgent }: { payload: DelegationAccumulatedPayload } & ResolverProp) {
return (
<div className="text-sm">
<span className="font-medium">{p.target_display_name || resolveAgent(p.target_agent_key)}</span>
<span className="text-muted-foreground"> result accumulated, </span>
<span>{p.siblings_remaining} sibling(s) remaining</span>
{p.elapsed_ms != null && p.elapsed_ms > 0 && (
<span className="ml-1 text-xs text-muted-foreground">
({formatDuration(p.elapsed_ms)})
</span>
)}
</div>
);
}
function DelegationAnnounceCard({ payload: p, resolveAgent }: { payload: DelegationAnnouncePayload } & ResolverProp) {
return (
<div className="space-y-1.5 text-sm">
<div className="flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-0.5">
<span className="font-medium">{p.source_display_name || resolveAgent(p.source_agent_key)}</span>
<span className="text-muted-foreground">announcing {p.results.length} result(s)</span>
<span className="text-xs text-muted-foreground">({formatDuration(p.total_elapsed_ms)})</span>
{p.has_media && (
<Badge variant="outline" className="shrink-0 text-xs">media</Badge>
)}
{p.completed_task_ids && p.completed_task_ids.length > 0 && (
<Badge variant="success" className="shrink-0 text-xs">
{p.completed_task_ids.length} task(s) auto-completed
</Badge>
)}
</div>
<div className="space-y-1">
{p.results.map((r) => (
<div key={r.agent_key} className="min-w-0 text-xs">
<div className="flex min-w-0 items-start gap-1.5">
<span className="shrink-0 font-medium">{r.display_name || resolveAgent(r.agent_key)}</span>
{r.has_media && (
<Badge variant="outline" className="shrink-0 text-xs">media</Badge>
)}
</div>
{r.content_preview && (
<p className="mt-0.5 break-words text-muted-foreground line-clamp-1">{r.content_preview}</p>
)}
</div>
))}
</div>
</div>
);
}
function QualityGateRetryCard({ payload: p, resolveAgent }: { payload: QualityGateRetryPayload } & ResolverProp) {
return (
<div className="space-y-1 text-sm">
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
<span className="truncate font-medium">{resolveAgent(p.target_agent_key)}</span>
<Badge variant="warning" className="shrink-0 text-xs">
retry {p.attempt}/{p.max_retries}
</Badge>
<Badge variant="outline" className="shrink-0 text-xs">{p.gate_type}</Badge>
</div>
{p.feedback && (
<p className="break-words text-xs text-muted-foreground line-clamp-2">{p.feedback}</p>
)}
</div>
);
}
@@ -6,7 +6,6 @@ import { formatRelativeTime } from "@/lib/format";
import type { TeamEventEntry } from "@/stores/use-team-event-store";
import { useAgentResolver } from "./use-agent-resolver";
import { getCategoryConfig } from "./event-categories";
import { DelegationEventCard } from "./delegation-event-cards";
import { TaskEventCard } from "./task-event-cards";
import { MessageEventCard } from "./message-event-card";
import { AgentEventCard } from "./agent-event-cards";
@@ -26,9 +25,7 @@ export function EventCard({ entry, resolveTeam }: EventCardProps) {
const CategoryIcon = config.icon;
let content: React.ReactNode;
if (event.startsWith("delegation.")) {
content = <DelegationEventCard entry={entry} resolveAgent={resolveAgent} />;
} else if (event.startsWith("team.task.")) {
if (event.startsWith("team.task.")) {
content = <TaskEventCard entry={entry} resolveAgent={resolveAgent} />;
} else if (event === "team.message.sent") {
content = <MessageEventCard entry={entry} resolveAgent={resolveAgent} />;
@@ -1,5 +1,4 @@
import {
ArrowRightLeft,
ListTodo,
MessageCircle,
Bot,
@@ -15,13 +14,6 @@ export interface EventCategoryConfig {
iconColor: string;
}
const delegation: EventCategoryConfig = {
label: "Delegation",
icon: ArrowRightLeft,
borderColor: "border-l-blue-500",
iconColor: "text-blue-500",
};
const teamTask: EventCategoryConfig = {
label: "Task",
icon: ListTodo,
@@ -58,7 +50,6 @@ const agentLink: EventCategoryConfig = {
};
export function getCategoryConfig(event: string): EventCategoryConfig {
if (event.startsWith("delegation.")) return delegation;
if (event.startsWith("team.task.")) return teamTask;
if (event === "team.message.sent") return teamMessage;
if (event === "agent") return agent;
@@ -24,7 +24,7 @@ export function EventDetailDialog({ entry, onClose }: EventDetailDialogProps) {
return (
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-2xl">
<DialogContent className="max-h-[85vh] flex flex-col sm:max-w-2xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Badge variant="secondary" className="font-mono text-xs">
@@ -36,7 +36,7 @@ export function EventDetailDialog({ entry, onClose }: EventDetailDialogProps) {
</DialogTitle>
</DialogHeader>
<div className="relative">
<div className="relative min-h-0 flex-1 -mx-4 px-4 sm:-mx-6 sm:px-6">
<Button
variant="ghost"
size="sm"
@@ -50,7 +50,7 @@ export function EventDetailDialog({ entry, onClose }: EventDetailDialogProps) {
)}
{copied ? t("detail.copied") : t("detail.copy")}
</Button>
<pre className="max-h-[60vh] overflow-auto whitespace-pre-wrap break-words rounded-md bg-muted p-4 text-sm leading-relaxed">
<pre className="max-h-full overflow-auto whitespace-pre-wrap break-words rounded-md bg-muted p-4 text-sm leading-relaxed">
<code>
<JsonHighlight json={json} />
</code>
+1 -2
View File
@@ -11,11 +11,10 @@ import { Methods } from "@/api/protocol";
import { EventCard } from "./event-sections";
import type { TeamData } from "@/types/team";
const EVENT_CATEGORY_VALUES = ["all", "delegation", "team.task", "team.message", "agent", "team.crud", "agent_link"] as const;
const EVENT_CATEGORY_VALUES = ["all", "team.task", "team.message", "agent", "team.crud", "agent_link"] as const;
const CATEGORY_KEY_MAP: Record<string, string> = {
"all": "categories.all",
"delegation": "categories.delegation",
"team.task": "categories.task",
"team.message": "categories.message",
"agent": "categories.agent",
+1 -1
View File
@@ -161,7 +161,7 @@ export function MCPFormDialog({ open, onOpenChange, server, onSubmit, onTest }:
<DialogTitle>{server ? t("form.editTitle") : t("form.createTitle")}</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-2 px-0.5 -mx-0.5 overflow-y-auto min-h-0">
<div className="grid gap-4 py-2 -mx-4 px-4 sm:-mx-6 sm:px-6 overflow-y-auto min-h-0">
<div className="grid gap-1.5">
<Label htmlFor="mcp-name">{t("form.name")}</Label>
<Input id="mcp-name" value={name} onChange={(e) => setName(slugify(e.target.value))} placeholder="my-mcp-server" />
+1 -1
View File
@@ -163,7 +163,7 @@ export function MCPGrantsDialog({
<DialogTitle>{t("grants.title", { name: server.display_name || server.name })}</DialogTitle>
</DialogHeader>
<div className="space-y-4 px-0.5 -mx-0.5 overflow-y-auto min-h-0">
<div className="space-y-4 -mx-4 px-4 sm:-mx-6 sm:px-6 overflow-y-auto min-h-0">
{/* Existing grants */}
{grants.length > 0 && (
<div className="space-y-2">
+1 -1
View File
@@ -87,7 +87,7 @@ export function MCPToolsDialog({ open, onOpenChange, server, onLoadTools }: MCPT
</Badge>
</div>
<div className="overflow-y-auto min-h-0 -mx-1 px-1 max-h-[50vh]">
<div className="overflow-y-auto min-h-0 -mx-4 px-4 sm:-mx-6 sm:px-6 max-h-[50vh]">
{filtered.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-6">{t("tools.noMatch")}</p>
) : (
@@ -64,7 +64,7 @@ export function KGEntityDetailDialog({ open, onOpenChange, agentId, entity, getE
</DialogTitle>
</DialogHeader>
<div className="flex-1 min-h-0 overflow-y-auto py-2 px-1 -mx-1 space-y-4">
<div className="flex-1 min-h-0 overflow-y-auto py-2 -mx-4 px-4 sm:-mx-6 sm:px-6 space-y-4">
{/* Entity info */}
{entity && (
<div className="grid grid-cols-1 gap-2 text-xs sm:grid-cols-2">
@@ -43,7 +43,7 @@ export function KGExtractDialog({ open, onOpenChange, onExtract }: KGExtractDial
<DialogTitle>{t("kg.extractDialog.title")}</DialogTitle>
</DialogHeader>
<div className="flex-1 min-h-0 overflow-y-auto py-2 px-1 -mx-1 space-y-4">
<div className="flex-1 min-h-0 overflow-y-auto py-2 -mx-4 px-4 sm:-mx-6 sm:px-6 space-y-4">
<ProviderModelSelect
provider={provider}
onProviderChange={setProvider}
@@ -101,7 +101,7 @@ export function MemoryCreateDialog({ open, onOpenChange, agentId: parentAgentId,
<DialogTitle>{t("createDialog.title")}</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-2 px-1 -mx-1 overflow-y-auto min-h-0">
<div className="grid gap-4 py-2 -mx-4 px-4 sm:-mx-6 sm:px-6 overflow-y-auto min-h-0">
{/* Agent selector */}
<div className="grid gap-1.5">
<Label htmlFor="mc-agent">{t("createDialog.agentId")}</Label>
@@ -136,7 +136,7 @@ export function MemoryDocumentDialog({ open, onOpenChange, agentId, document }:
</button>
</div>
<div className="flex-1 min-h-0 overflow-y-auto py-2 px-1 -mx-1">
<div className="flex-1 min-h-0 overflow-y-auto py-2 -mx-4 px-4 sm:-mx-6 sm:px-6">
{tab === "content" && (
<div className="grid gap-3">
{detail && (
@@ -65,7 +65,7 @@ export function MemorySearchDialog({ open, onOpenChange, agentId }: MemorySearch
</Button>
</div>
<div className="flex-1 min-h-0 overflow-y-auto mt-2">
<div className="flex-1 min-h-0 overflow-y-auto mt-2 -mx-4 px-4 sm:-mx-6 sm:px-6">
{results.length === 0 ? (
<div className="py-12 text-center text-muted-foreground text-sm">
{searching ? t("searchDialog.searching") : t("searchDialog.noResults")}
@@ -32,7 +32,7 @@ export function MessageListDialog({
return (
<Dialog open onOpenChange={() => onClose()}>
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-2xl">
<DialogContent className="max-h-[85vh] flex flex-col sm:max-w-2xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 truncate">
{t("dialog.title", { name: group.group_title || group.history_key })}
@@ -40,37 +40,39 @@ export function MessageListDialog({
</DialogTitle>
</DialogHeader>
{loading ? (
<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>
) : messages.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">{t("dialog.noMessages")}</p>
) : (
<div className="space-y-3">
{messages.map((msg) => (
<div
key={msg.id}
className={
"rounded-md border p-3 text-sm" +
(msg.is_summary ? " border-amber-500/30 bg-amber-500/5" : "")
}
>
<div className="mb-1.5 flex items-center gap-2">
<span className="font-medium">{msg.sender}</span>
<span className="text-xs text-muted-foreground">({msg.sender_id})</span>
{msg.is_summary && (
<Badge variant="warning" className="text-xs">{t("dialog.summary")}</Badge>
)}
<span className="ml-auto text-xs text-muted-foreground">
{formatDate(msg.created_at)}
</span>
<div className="overflow-y-auto min-h-0 -mx-4 px-4 sm:-mx-6 sm:px-6">
{loading ? (
<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>
) : messages.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">{t("dialog.noMessages")}</p>
) : (
<div className="space-y-3">
{messages.map((msg) => (
<div
key={msg.id}
className={
"rounded-md border p-3 text-sm" +
(msg.is_summary ? " border-amber-500/30 bg-amber-500/5" : "")
}
>
<div className="mb-1.5 flex items-center gap-2">
<span className="font-medium">{msg.sender}</span>
<span className="text-xs text-muted-foreground">({msg.sender_id})</span>
{msg.is_summary && (
<Badge variant="warning" className="text-xs">{t("dialog.summary")}</Badge>
)}
<span className="ml-auto text-xs text-muted-foreground">
{formatDate(msg.created_at)}
</span>
</div>
<p className="whitespace-pre-wrap break-words text-sm">{msg.body}</p>
</div>
<p className="whitespace-pre-wrap break-words text-sm">{msg.body}</p>
</div>
))}
</div>
)}
))}
</div>
)}
</div>
</DialogContent>
</Dialog>
);
@@ -145,7 +145,7 @@ export function ProviderFormDialog({ open, onOpenChange, provider, onSubmit, exi
<DialogTitle>{isEdit ? t("form.editTitle") : t("form.createTitle")}</DialogTitle>
<DialogDescription>{t("form.configure")}</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4 px-0.5 -mx-0.5 overflow-y-auto min-h-0">
<div className="space-y-4 py-4 -mx-4 px-4 sm:-mx-6 sm:px-6 overflow-y-auto min-h-0">
{/* Provider type selector — always shown in create mode */}
{!isEdit && (
<ProviderTypeSelect
@@ -132,7 +132,7 @@ export function SkillDetailDialog({
{hasFiles && <TabsTrigger value="files">{t("detail.files")}</TabsTrigger>}
</TabsList>
<TabsContent value="content" className="flex-1 overflow-y-auto mt-2">
<TabsContent value="content" className="flex-1 overflow-y-auto mt-2 -mx-4 px-4 sm:-mx-6 sm:px-6">
{skill.content ? (
<div className="overflow-hidden rounded-md border bg-muted/30 p-4">
<MarkdownRenderer content={skill.content} />
@@ -58,19 +58,22 @@ export function BoardHeader({ team, members, onBack, onDelete, onSettings, onMem
</div>
{/* Actions */}
<Button variant="ghost" size="icon" onClick={onMembers} className="shrink-0" title={t("members.title")}>
<Button variant="ghost" size="sm" onClick={onMembers} className="shrink-0 gap-1.5">
<Users className="h-4 w-4" />
<span className="hidden sm:inline">{t("members.title")}</span>
</Button>
<Button variant="ghost" size="icon" onClick={onSettings} className="shrink-0" title={t("detail.tabs.settings")}>
<Button variant="ghost" size="sm" onClick={onSettings} className="shrink-0 gap-1.5">
<Settings className="h-4 w-4" />
<span className="hidden sm:inline">{t("detail.tabs.settings")}</span>
</Button>
<Button
variant="ghost"
size="icon"
className="shrink-0 text-muted-foreground hover:text-destructive"
size="sm"
className="shrink-0 gap-1.5 text-muted-foreground hover:text-destructive"
onClick={onDelete}
>
<Trash2 className="h-4 w-4" />
<span className="hidden sm:inline">{t("delete.title")}</span>
</Button>
</div>
);
@@ -34,7 +34,7 @@ export function TeamInfoDialog({
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[85vh] w-[95vw] overflow-y-auto sm:max-w-2xl">
<DialogContent className="max-h-[90vh] w-[95vw] flex flex-col sm:max-w-2xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
{team.name}
@@ -52,6 +52,7 @@ export function TeamInfoDialog({
</DialogTitle>
</DialogHeader>
<div className="overflow-y-auto min-h-0 -mx-4 px-4 sm:-mx-6 sm:px-6 space-y-4">
{/* Team overview */}
<div className="grid grid-cols-1 gap-3 rounded-lg border p-4 text-sm sm:grid-cols-2">
{team.description && (
@@ -72,6 +73,7 @@ export function TeamInfoDialog({
{/* Settings form */}
<TeamSettingsTab teamId={teamId} team={team} onSaved={() => { onSaved(); onOpenChange(false); }} />
</div>
</DialogContent>
</Dialog>
@@ -1,30 +0,0 @@
import { useState, useEffect, useCallback } from "react";
import { useHttp } from "@/hooks/use-ws";
import type { DelegationHistoryRecord } from "@/types/delegation";
export function useTeamDelegations(teamId: string) {
const http = useHttp();
const [records, setRecords] = useState<DelegationHistoryRecord[]>([]);
const [loading, setLoading] = useState(true);
const load = useCallback(async () => {
setLoading(true);
try {
const res = await http.get<{ records: DelegationHistoryRecord[] }>("/v1/delegations", {
team_id: teamId,
limit: "50",
});
setRecords(res.records ?? []);
} catch {
// ignore
} finally {
setLoading(false);
}
}, [http, teamId]);
useEffect(() => {
load();
}, [load]);
return { records, loading, refresh: load };
}
@@ -68,7 +68,7 @@ export function TaskDetailDialog({
return (
<Dialog open onOpenChange={() => onClose()}>
<DialogContent className="max-h-[85vh] w-[95vw] overflow-y-auto sm:max-w-4xl">
<DialogContent className="max-h-[85vh] w-[95vw] flex flex-col sm:max-w-4xl">
<DialogHeader>
<div className="flex items-center justify-between gap-2">
<DialogTitle className="flex items-center gap-2">
@@ -85,7 +85,7 @@ export function TaskDetailDialog({
</div>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-4 overflow-y-auto min-h-0 -mx-4 px-4 sm:-mx-6 sm:px-6">
{/* Subject */}
<div className="rounded-md border p-3">
<p className="mb-1 text-xs font-medium text-muted-foreground">{t("tasks.detail.subject")}</p>
@@ -116,7 +116,7 @@ export function TeamCreateDialog({ open, onOpenChange, onCreate }: TeamCreateDia
<DialogHeader>
<DialogTitle>{t("create.title")}</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4 px-0.5 -mx-0.5 overflow-y-auto min-h-0">
<div className="space-y-4 py-4 -mx-4 px-4 sm:-mx-6 sm:px-6 overflow-y-auto min-h-0">
<div className="space-y-2">
<Label htmlFor="teamName">{t("create.name")}</Label>
<Input
@@ -1,110 +0,0 @@
import { useState } from "react";
import { ArrowRightLeft, RefreshCw } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/shared/empty-state";
import { DeferredSpinner } from "@/components/shared/loading-skeleton";
import { useTranslation } from "react-i18next";
import { useMinLoading } from "@/hooks/use-min-loading";
import { formatDate, formatDuration } from "@/lib/format";
import { useTeamDelegations } from "./hooks/use-team-delegations";
import { useDelegations } from "@/pages/delegations/hooks/use-delegations";
import { useTraces } from "@/pages/traces/hooks/use-traces";
import { DelegationDetailDialog } from "@/pages/delegations/delegation-detail-dialog";
interface TeamDelegationsTabProps {
teamId: string;
}
export function TeamDelegationsTab({ teamId }: TeamDelegationsTabProps) {
const { t } = useTranslation("teams");
const { records, loading, refresh } = useTeamDelegations(teamId);
const { getDelegation } = useDelegations();
const { getTrace } = useTraces();
const [selectedId, setSelectedId] = useState<string | null>(null);
const spinning = useMinLoading(loading);
if (loading && records.length === 0) return <DeferredSpinner />;
if (records.length === 0) {
return (
<EmptyState
icon={ArrowRightLeft}
title={t("delegations.noTitle")}
description={t("delegations.noDescription")}
/>
);
}
return (
<>
<div className="mb-4 flex justify-end">
<Button variant="outline" size="sm" onClick={refresh} disabled={spinning} className="gap-1">
<RefreshCw className={"h-3.5 w-3.5" + (spinning ? " animate-spin" : "")} /> {t("delegations.refresh")}
</Button>
</div>
<div className="rounded-md border overflow-x-auto">
<table className="w-full min-w-[600px] text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="px-4 py-3 text-left font-medium">{t("delegations.columns.sourceTarget")}</th>
<th className="px-4 py-3 text-left font-medium">{t("delegations.columns.task")}</th>
<th className="px-4 py-3 text-left font-medium">{t("delegations.columns.status")}</th>
<th className="px-4 py-3 text-left font-medium">{t("delegations.columns.duration")}</th>
<th className="px-4 py-3 text-left font-medium">{t("delegations.columns.time")}</th>
</tr>
</thead>
<tbody>
{records.map((d) => (
<tr
key={d.id}
className="cursor-pointer border-b last:border-0 hover:bg-muted/30"
onClick={() => setSelectedId(d.id)}
>
<td className="px-4 py-3">
<span className="font-medium">{d.source_agent_key || d.source_agent_id.slice(0, 8)}</span>
<span className="mx-1 text-muted-foreground">&rarr;</span>
<span className="font-medium">{d.target_agent_key || d.target_agent_id.slice(0, 8)}</span>
</td>
<td className="max-w-[250px] truncate px-4 py-3 text-muted-foreground">
{d.task}
</td>
<td className="px-4 py-3">
<StatusBadge status={d.status} />
</td>
<td className="px-4 py-3 text-muted-foreground">
{formatDuration(d.duration_ms)}
</td>
<td className="px-4 py-3 text-muted-foreground">
{formatDate(d.created_at)}
</td>
</tr>
))}
</tbody>
</table>
</div>
{selectedId && (
<DelegationDetailDialog
delegationId={selectedId}
onClose={() => setSelectedId(null)}
getDelegation={getDelegation}
getTrace={getTrace}
/>
)}
</>
);
}
function StatusBadge({ status }: { status: string }) {
const variant =
status === "completed"
? "success"
: status === "failed"
? "destructive"
: status === "running" || status === "pending"
? "info"
: "secondary";
return <Badge variant={variant} className="text-xs">{status || "unknown"}</Badge>;
}
@@ -36,7 +36,7 @@ export function TeamVersionModal({ open, onOpenChange }: TeamVersionModalProps)
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[85vh] w-[95vw] overflow-y-auto sm:max-w-3xl">
<DialogContent className="max-h-[85vh] w-[95vw] flex flex-col sm:max-w-3xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
{t("settings.versionModal.title")}
@@ -47,6 +47,7 @@ export function TeamVersionModal({ open, onOpenChange }: TeamVersionModalProps)
<DialogDescription className="sr-only">{t("settings.versionModal.title")}</DialogDescription>
</DialogHeader>
<div className="overflow-y-auto min-h-0 space-y-4 -mx-4 px-4 sm:-mx-6 sm:px-6">
{/* Version columns header */}
<div className="grid grid-cols-2 gap-3">
<div className="rounded-lg border bg-muted/30 p-3 text-center">
@@ -117,6 +118,8 @@ export function TeamVersionModal({ open, onOpenChange }: TeamVersionModalProps)
<p>{t("settings.versionModal.betaNote")}</p>
</div>
</div>
<div className="flex justify-end">
<Button variant="outline" size="sm" onClick={() => onOpenChange(false)}>
{t("settings.versionModal.gotIt")}
@@ -187,13 +187,13 @@ export function TeamWorkspaceTab({ teamId, scopes }: TeamWorkspaceTabProps) {
{/* File content dialog */}
<Dialog open={!!selectedFile} onOpenChange={() => setSelectedFile(null)}>
<DialogContent className="max-h-[80vh] max-w-2xl overflow-y-auto">
<DialogContent className="max-h-[80vh] max-w-2xl flex flex-col">
<DialogHeader>
<DialogTitle className="font-mono text-sm">
{selectedFile?.file.name}
</DialogTitle>
</DialogHeader>
<pre className="mt-2 max-h-[60vh] overflow-auto rounded-md bg-muted p-4 text-xs">
<pre className="flex-1 min-h-0 overflow-auto rounded-md bg-muted p-4 text-xs -mx-4 sm:-mx-6">
{selectedFile?.content || t("workspace.detail.empty")}
</pre>
</DialogContent>
@@ -135,7 +135,7 @@ export function TraceDetailDialog({ traceId, onClose, getTrace, onNavigateTrace
return (
<Dialog open onOpenChange={() => onClose()}>
<DialogContent className="max-h-[85vh] w-[95vw] overflow-y-auto sm:max-w-6xl">
<DialogContent className="max-h-[85vh] w-[95vw] flex flex-col sm:max-w-6xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 pr-8">
{t("detail.title")}
@@ -163,6 +163,7 @@ export function TraceDetailDialog({ traceId, onClose, getTrace, onNavigateTrace
</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" />
@@ -253,6 +254,7 @@ export function TraceDetailDialog({ traceId, onClose, getTrace, onNavigateTrace
)}
</div>
)}
</div>
</DialogContent>
</Dialog>
);
-4
View File
@@ -66,9 +66,6 @@ const TtsPage = lazy(() =>
const EventsPage = lazy(() =>
import("@/pages/events/events-page").then((m) => ({ default: m.EventsPage })),
);
const DelegationsPage = lazy(() =>
import("@/pages/delegations/delegations-page").then((m) => ({ default: m.DelegationsPage })),
);
const StoragePage = lazy(() =>
import("@/pages/storage/storage-page").then((m) => ({ default: m.StoragePage })),
);
@@ -149,7 +146,6 @@ export function AppRoutes() {
<Route path={ROUTES.TRACES} element={<TracesPage key="list" />} />
<Route path={ROUTES.TRACE_DETAIL} element={<TracesPage key="detail" />} />
<Route path={ROUTES.EVENTS} element={<EventsPage />} />
<Route path={ROUTES.DELEGATIONS} element={<DelegationsPage />} />
<Route path={ROUTES.USAGE} element={<Navigate to={ROUTES.OVERVIEW} replace />} />
<Route path={ROUTES.ACTIVITY} element={<ActivityPage />} />
<Route path={ROUTES.CHANNELS} element={<ChannelsPage key="list" />} />
-33
View File
@@ -1,33 +0,0 @@
/** Delegation history types matching Go internal/store/team_store.go */
export interface DelegationHistoryRecord {
id: string;
source_agent_id: string;
target_agent_id: string;
team_id?: string;
team_task_id?: string;
user_id?: string;
task: string;
mode: string;
status: string;
result?: string;
error?: string;
iterations: number;
trace_id?: string;
duration_ms: number;
completed_at?: string;
created_at: string;
// Joined fields
source_agent_key?: string;
target_agent_key?: string;
}
export interface DelegationListFilters {
source_agent_id?: string;
target_agent_id?: string;
team_id?: string;
user_id?: string;
status?: string;
limit?: number;
offset?: number;
}
-93
View File
@@ -1,98 +1,5 @@
/** TypeScript interfaces mirroring Go pkg/protocol/team_events.go */
// --- Delegation events ---
export interface DelegationEventPayload {
delegation_id: string;
source_agent_id: string;
source_agent_key: string;
source_display_name?: string;
target_agent_id: string;
target_agent_key: string;
target_display_name?: string;
user_id: string;
channel: string;
chat_id: string;
mode: string;
task?: string;
team_id?: string;
team_task_id?: string;
status?: string;
elapsed_ms?: number;
error?: string;
created_at: string;
}
export interface DelegationProgressItem {
delegation_id: string;
target_agent_key: string;
target_display_name?: string;
elapsed_ms: number;
team_task_id?: string;
activity?: string; // "thinking", "tool_exec", "compacting"
tool?: string; // current tool name (when activity == "tool_exec")
}
export interface DelegationProgressPayload {
source_agent_id: string;
source_agent_key: string;
user_id: string;
channel: string;
chat_id: string;
team_id?: string;
active_delegations: DelegationProgressItem[];
}
export interface DelegationAccumulatedPayload {
delegation_id: string;
source_agent_id: string;
source_agent_key: string;
target_agent_key: string;
target_display_name?: string;
user_id: string;
channel: string;
chat_id: string;
team_id?: string;
team_task_id?: string;
siblings_remaining: number;
elapsed_ms?: number;
}
export interface DelegationAnnounceResultSummary {
agent_key: string;
display_name?: string;
has_media: boolean;
content_preview?: string;
}
export interface DelegationAnnouncePayload {
source_agent_id: string;
source_agent_key: string;
source_display_name?: string;
user_id: string;
channel: string;
chat_id: string;
team_id?: string;
results: DelegationAnnounceResultSummary[];
completed_task_ids?: string[];
total_elapsed_ms: number;
has_media: boolean;
}
export interface QualityGateRetryPayload {
delegation_id: string;
target_agent_key: string;
user_id: string;
channel: string;
chat_id: string;
team_id?: string;
team_task_id?: string;
gate_type: string;
attempt: number;
max_retries: number;
feedback?: string;
}
// --- Team task events ---
export interface TeamTaskEventPayload {