refactor(agents): redesign agent detail workbench

This commit is contained in:
Jiayuan Zhang
2026-07-11 17:41:07 +08:00
parent 19cfa7189b
commit c438dd71a8
10 changed files with 1081 additions and 532 deletions

View File

@@ -11,10 +11,7 @@ import type {
AgentRuntime,
MemberWithUser,
} from "@multica/core/types";
import {
AGENT_DESCRIPTION_MAX_LENGTH,
type AgentPresenceDetail,
} from "@multica/core/agents";
import { AGENT_DESCRIPTION_MAX_LENGTH } from "@multica/core/agents";
import { isImeComposing } from "@multica/core/utils";
import { useTimeAgo } from "../../i18n";
import { Button } from "@multica/ui/components/ui/button";
@@ -34,26 +31,18 @@ import {
PopoverTrigger,
} from "@multica/ui/components/ui/popover";
import { PropRow } from "../../common/prop-row";
import { availabilityConfig } from "../presence";
import { CharCounter } from "./char-counter";
import { useT } from "../../i18n";
import { ConcurrencyPicker } from "./inspector/concurrency-picker";
import { ModelPicker } from "./inspector/model-picker";
import { RuntimePicker } from "./inspector/runtime-picker";
import { SkillAttach } from "./inspector/skill-attach";
import { ThinkingPropRow } from "./inspector/thinking-prop-row";
import { AccessPicker } from "./inspector/access-picker";
import { LarkAgentBindButton } from "../../settings/components/lark-tab";
import { SlackAgentBindButton } from "../../settings/components/slack-tab";
interface InspectorProps {
agent: Agent;
runtime: AgentRuntime | null;
owner: MemberWithUser | null;
presence: AgentPresenceDetail | null | undefined;
// Below: needed for inline edit. The inspector now owns the editing surface
// (no Settings tab anymore), so the parent has to pass through everything
// a write needs.
runtimes: AgentRuntime[];
members: MemberWithUser[];
currentUserId: string | null;
@@ -67,36 +56,23 @@ interface InspectorProps {
*/
canEdit: boolean;
onUpdate: (id: string, data: Record<string, unknown>) => Promise<void>;
/**
* Focus the overview pane's Integrations tab. The inspector's Lark status
* row is read-only and deep-links here; Manage / Disconnect live in the
* tab so the destructive action exists in exactly one place.
*/
onShowIntegrations: () => void;
}
/**
* Left 320px column of the agent detail page. Holds the agent's identity card
* (avatar / name / description / status), inline-editable properties, and
* skills.
*
* **All editing happens here** — there is no separate Settings tab. The
* trade-off is that the inspector carries some weight (4 inline pickers plus
* 3 popovers for name/description/avatar), but it eliminates the "see vs
* edit" mode split that the previous Settings tab created. Users no longer
* have to switch tabs and hunt for the field they were already looking at.
* General settings surface. Identity and execution controls are grouped by
* product meaning instead of being squeezed into a persistent inspector next
* to every task view. This keeps the workbench read-oriented while preserving
* the existing permission-aware picker and optimistic-update behaviour.
*/
export function AgentDetailInspector({
agent,
runtime,
owner,
presence,
runtimes,
members,
currentUserId,
canEdit,
onUpdate,
onShowIntegrations,
}: InspectorProps) {
const { t } = useT("agents");
const timeAgo = useTimeAgo();
@@ -104,157 +80,111 @@ export function AgentDetailInspector({
const isOnline = runtime?.status === "online";
return (
<aside className="flex w-full flex-col rounded-lg border bg-background md:h-full md:min-h-0 md:overflow-y-auto">
{/* Identity */}
<div className="flex flex-col gap-3 border-b px-5 pb-5 pt-5">
<AvatarEditor agent={agent} canEdit={canEdit} onUpdate={update} />
<NameAndDescription
agent={agent}
canEdit={canEdit}
onUpdate={update}
/>
<PresenceBadge presence={presence} />
</div>
{/* Properties — editable when canEdit. When the current user lacks
permission, each picker self-renders a static read-only display so
the value is visible but not interactive. */}
<Section label={t(($) => $.inspector.section_properties)}>
<PropRow label={t(($) => $.inspector.prop_runtime)} interactive={false}>
<RuntimePicker
value={agent.runtime_id}
runtimes={runtimes}
members={members}
currentUserId={currentUserId}
canEdit={canEdit}
onChange={(id) => update({ runtime_id: id })}
/>
</PropRow>
<PropRow label={t(($) => $.inspector.prop_model)} interactive={false}>
<ModelPicker
runtimeId={agent.runtime_id}
runtimeOnline={!!isOnline}
value={agent.model ?? ""}
canEdit={canEdit}
onChange={(m) => update({ model: m })}
/>
</PropRow>
<ThinkingPropRow
runtimeId={agent.runtime_id}
runtimeOnline={!!isOnline}
provider={runtime?.provider ?? ""}
model={agent.model ?? ""}
value={agent.thinking_level ?? ""}
canEdit={canEdit}
onChange={(v) => update({ thinking_level: v })}
/>
<PropRow label={t(($) => $.inspector.prop_visibility)} interactive={false}>
<AccessPicker
permissionMode={agent.permission_mode}
invocationTargets={agent.invocation_targets}
visibility={agent.visibility}
members={members}
// Access is OWNER-ONLY (MUL-3963): a workspace admin can edit other
// agent properties (canEdit) but NOT who may run the agent. Gate the
// picker on ownership specifically so non-owners get the read-only
// state instead of a control the backend would reject with 403.
canEdit={
currentUserId !== null && agent.owner_id === currentUserId
}
hasComposioAllowlist={
(agent.composio_toolkit_allowlist ?? []).length > 0
}
onChange={(next) => update(next)}
/>
</PropRow>
<PropRow label={t(($) => $.inspector.prop_concurrency)} interactive={false}>
<ConcurrencyPicker
value={agent.max_concurrent_tasks}
canEdit={canEdit}
onChange={(n) => update({ max_concurrent_tasks: n })}
/>
</PropRow>
</Section>
{/* Details — read-only (no hover, no chip styling — these aren't clickable) */}
<Section label={t(($) => $.inspector.section_details)}>
{owner && (
<PropRow label={t(($) => $.inspector.prop_owner)} interactive={false}>
<span className="flex min-w-0 items-center gap-1.5">
<ActorAvatar
actorType="member"
actorId={owner.user_id}
size="xs"
<div className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_minmax(300px,380px)]">
<div className="space-y-4">
<SettingsCard title={t(($) => $.inspector.section_profile)}>
<div className="flex flex-col gap-4 sm:flex-row sm:items-start">
<AvatarEditor agent={agent} canEdit={canEdit} onUpdate={update} />
<div className="min-w-0 flex-1">
<NameAndDescription
agent={agent}
canEdit={canEdit}
onUpdate={update}
/>
<span className="truncate">{owner.name}</span>
</span>
</PropRow>
)}
<PropRow label={t(($) => $.inspector.prop_created)} interactive={false}>
<span className="text-muted-foreground">
{timeAgo(agent.created_at)}
</span>
</PropRow>
<PropRow label={t(($) => $.inspector.prop_updated)} interactive={false}>
<span className="text-muted-foreground">
{timeAgo(agent.updated_at)}
</span>
</PropRow>
</Section>
</div>
</div>
</SettingsCard>
{/* Skills */}
<div className="flex flex-col border-b px-5 py-4">
<div className="mb-2 flex items-center gap-2">
<span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
{t(($) => $.inspector.section_skills)}
</span>
<span className="font-mono text-[10px] tabular-nums text-muted-foreground/70">
{agent.skills.length}
</span>
</div>
<div className="flex flex-wrap gap-1">
{agent.skills.map((s) => (
<span
key={s.id}
className="rounded-md bg-muted px-1.5 py-0.5 font-mono text-[10px] font-medium text-muted-foreground"
>
{s.name}
</span>
))}
<SkillAttach agent={agent} canEdit={canEdit} />
</div>
<SettingsCard title={t(($) => $.inspector.section_execution)}>
<PropertyGrid>
<PropRow label={t(($) => $.inspector.prop_runtime)} interactive={false}>
<RuntimePicker
value={agent.runtime_id}
runtimes={runtimes}
members={members}
currentUserId={currentUserId}
canEdit={canEdit}
onChange={(id) => update({ runtime_id: id })}
/>
</PropRow>
<PropRow label={t(($) => $.inspector.prop_model)} interactive={false}>
<ModelPicker
runtimeId={agent.runtime_id}
runtimeOnline={!!isOnline}
value={agent.model ?? ""}
canEdit={canEdit}
onChange={(m) => update({ model: m })}
/>
</PropRow>
<ThinkingPropRow
runtimeId={agent.runtime_id}
runtimeOnline={!!isOnline}
provider={runtime?.provider ?? ""}
model={agent.model ?? ""}
value={agent.thinking_level ?? ""}
canEdit={canEdit}
onChange={(v) => update({ thinking_level: v })}
/>
<PropRow label={t(($) => $.inspector.prop_concurrency)} interactive={false}>
<ConcurrencyPicker
value={agent.max_concurrent_tasks}
canEdit={canEdit}
onChange={(n) => update({ max_concurrent_tasks: n })}
/>
</PropRow>
</PropertyGrid>
</SettingsCard>
</div>
{/* Integrations — surfaces external-channel bind entry points
(Lark + Slack today; Discord in the future). Each bind button
self-hides when its server-side install capability gate is
closed, so this section may render empty on deployments without
a configured channel — that's intentional and matches the
"don't surface a flow that will fail" guarantee. We only mount
it for editors: viewers shouldn't see a CTA they can't action. */}
{canEdit && (
<div className="flex flex-col px-5 py-4">
<div className="mb-2 flex items-center gap-2">
<span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
{t(($) => $.inspector.section_integrations)}
</span>
</div>
<div className="flex flex-wrap gap-2">
<LarkAgentBindButton
agentId={agent.id}
agentName={agent.name}
agentOwnerId={agent.owner_id}
onShowConnectedDetails={onShowIntegrations}
/>
<SlackAgentBindButton
agentId={agent.id}
agentName={agent.name}
onShowConnectedDetails={onShowIntegrations}
/>
</div>
</div>
)}
</aside>
<div className="space-y-4">
<SettingsCard title={t(($) => $.inspector.section_access)}>
<PropertyGrid>
<PropRow label={t(($) => $.inspector.prop_visibility)} interactive={false}>
<AccessPicker
permissionMode={agent.permission_mode}
invocationTargets={agent.invocation_targets}
visibility={agent.visibility}
members={members}
canEdit={
currentUserId !== null && agent.owner_id === currentUserId
}
hasComposioAllowlist={
(agent.composio_toolkit_allowlist ?? []).length > 0
}
onChange={(next) => update(next)}
/>
</PropRow>
</PropertyGrid>
</SettingsCard>
<SettingsCard title={t(($) => $.inspector.section_details)}>
<PropertyGrid>
{owner && (
<PropRow label={t(($) => $.inspector.prop_owner)} interactive={false}>
<span className="flex min-w-0 items-center gap-1.5">
<ActorAvatar
actorType="member"
actorId={owner.user_id}
size="xs"
/>
<span className="truncate">{owner.name}</span>
</span>
</PropRow>
)}
<PropRow label={t(($) => $.inspector.prop_created)} interactive={false}>
<span className="text-muted-foreground">
{timeAgo(agent.created_at)}
</span>
</PropRow>
<PropRow label={t(($) => $.inspector.prop_updated)} interactive={false}>
<span className="text-muted-foreground">
{timeAgo(agent.updated_at)}
</span>
</PropRow>
</PropertyGrid>
</SettingsCard>
</div>
</div>
);
}
@@ -262,21 +192,27 @@ export function AgentDetailInspector({
// Layout helpers
// ---------------------------------------------------------------------------
function Section({
label,
function SettingsCard({
title,
children,
}: {
label: string;
title: string;
children: ReactNode;
}) {
return (
<div className="border-b px-5 py-4">
<div className="mb-1 -mx-2 px-2 text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
{label}
</div>
<div className="grid grid-cols-[auto_1fr] gap-x-2 gap-y-0.5">
{children}
<section className="rounded-lg border bg-background">
<div className="border-b px-5 py-3.5">
<h2 className="text-sm font-semibold">{title}</h2>
</div>
<div className="p-5">{children}</div>
</section>
);
}
function PropertyGrid({ children }: { children: ReactNode }) {
return (
<div className="grid grid-cols-[auto_minmax(0,1fr)] gap-x-3 gap-y-1">
{children}
</div>
);
}
@@ -495,7 +431,11 @@ function DescriptionEditorBody({
onClick={() => void commit()}
disabled={saving || overLimit || !dirty}
>
{saving ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : t(($) => $.inspector.save)}
{saving ? (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
t(($) => $.inspector.save)
)}
</Button>
</DialogFooter>
</>
@@ -629,7 +569,7 @@ function InlineEditPopover({
disabled={saving || draft === value}
>
{saving ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
t(($) => $.inspector.save)
)}
@@ -640,35 +580,3 @@ function InlineEditPopover({
</Popover>
);
}
// ---------------------------------------------------------------------------
// Presence badge — unchanged from the previous version
// ---------------------------------------------------------------------------
function PresenceBadge({
presence,
}: {
presence: AgentPresenceDetail | null | undefined;
}) {
const { t } = useT("agents");
// Archived is carried by the unified presence (deriveAgentPresenceDetail
// sets availability="archived" before any runtime/task scan), so the
// normal path below renders the gray "Archived" badge with no special
// case here — same single source of truth as every other status surface.
if (!presence) {
return (
<span className="inline-flex h-5 w-20 animate-pulse rounded-md bg-muted" />
);
}
const av = availabilityConfig[presence.availability];
return (
<div className="flex flex-wrap items-center gap-1.5">
<span
className={`inline-flex items-center gap-1.5 rounded-md border px-1.5 py-0.5 text-xs ${av.textClass}`}
>
<span className={`h-1.5 w-1.5 rounded-full ${av.dotClass}`} />
{t(($) => $.availability[presence.availability])}
</span>
</div>
);
}

View File

@@ -4,13 +4,21 @@ import { useState } from "react";
import {
AlertCircle,
ArrowLeft,
Bot,
Clock3,
Lock,
MoreHorizontal,
Plus,
Server,
Trash2,
} from "lucide-react";
import { toast } from "sonner";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import type { Agent, UpdateAgentRequest } from "@multica/core/types";
import type {
Agent,
AgentRuntime,
UpdateAgentRequest,
} from "@multica/core/types";
import {
type AgentPresenceDetail,
useWorkspacePresenceMap,
@@ -18,6 +26,7 @@ import {
import { api, ApiError } from "@multica/core/api";
import { useAuthStore } from "@multica/core/auth";
import { useWorkspaceId } from "@multica/core/hooks";
import { useModalStore } from "@multica/core/modals";
import { useWorkspacePaths } from "@multica/core/paths";
import {
agentListOptions,
@@ -44,12 +53,12 @@ import {
} from "@multica/ui/components/ui/dropdown-menu";
import { Skeleton } from "@multica/ui/components/ui/skeleton";
import { AppLink, useNavigation } from "../../navigation";
import { BreadcrumbHeader } from "../../layout/breadcrumb-header";
import { PageHeader } from "../../layout/page-header";
import { availabilityConfig } from "../presence";
import { AgentDetailInspector } from "./agent-detail-inspector";
import { ActorAvatar } from "../../common/actor-avatar";
import { AgentPresenceIndicator } from "./agent-presence-indicator";
import { VisibilityBadge } from "./visibility-badge";
import { AgentOverviewPane, type DetailTab } from "./agent-overview-pane";
import { useT } from "../../i18n";
import { useT, useTimeAgo } from "../../i18n";
interface AgentDetailPageProps {
agentId: string;
@@ -97,7 +106,7 @@ export function AgentDetailPage({ agentId }: AgentDetailPageProps) {
// signature handles the not-found / loading case internally so the early
// returns below don't violate the rules of hooks. Backend gates archive
// and restore identically to edit, so a single `canEdit` covers them all.
const { canEdit } = useAgentPermissions(agent, wsId);
const { canAssign, canEdit } = useAgentPermissions(agent, wsId);
const [confirmArchive, setConfirmArchive] = useState(false);
@@ -248,9 +257,16 @@ export function AgentDetailPage({ agentId }: AgentDetailPageProps) {
<div className="flex flex-1 min-h-0 flex-col">
<DetailHeader
agent={agent}
runtime={runtime}
presence={presence}
backHref={paths.agents()}
canAssign={canAssign.allowed}
canArchive={canEdit.allowed}
onAssign={() =>
useModalStore
.getState()
.open("quick-create-issue", { agent_id: agent.id })
}
onArchive={() => setConfirmArchive(true)}
/>
@@ -283,25 +299,17 @@ export function AgentDetailPage({ agentId }: AgentDetailPageProps) {
</div>
)}
<div className="flex flex-1 min-h-0 flex-col gap-3 overflow-y-auto p-3 md:grid md:grid-cols-[320px_minmax(0,1fr)] md:gap-4 md:overflow-hidden md:p-6">
<AgentDetailInspector
<div className="flex flex-1 min-h-0 flex-col overflow-hidden">
<AgentOverviewPane
agent={agent}
runtime={runtime}
owner={owner}
presence={presence}
runtimes={runtimes}
members={members}
onUpdate={handleUpdate}
currentUserId={currentUser?.id ?? null}
canEdit={canEdit.allowed}
onUpdate={handleUpdate}
onShowIntegrations={() => setTabNavIntent("integrations")}
/>
<AgentOverviewPane
agent={agent}
runtimes={runtimes}
onUpdate={handleUpdate}
currentUserId={currentUser?.id ?? null}
navIntent={tabNavIntent}
onNavIntentHandled={() => setTabNavIntent(null)}
/>
@@ -355,64 +363,113 @@ export function AgentDetailPage({ agentId }: AgentDetailPageProps) {
function DetailHeader({
agent,
runtime,
presence,
backHref,
canAssign,
canArchive,
onAssign,
onArchive,
}: {
agent: Agent;
runtime: AgentRuntime | null;
presence: AgentPresenceDetail | null;
backHref: string;
canAssign: boolean;
canArchive: boolean;
onAssign: () => void;
onArchive: () => void;
}) {
const { t } = useT("agents");
const timeAgo = useTimeAgo();
const isArchived = !!agent.archived_at;
const av = presence
? { ...availabilityConfig[presence.availability], label: t(($) => $.availability[presence.availability]) }
: null;
// Last-task state is intentionally not surfaced in the header — the
// Recent work section on this page already shows the same information
// (and richer: titles, timestamps, error messages). Showing "Completed"
// up here was redundant chrome.
return (
<BreadcrumbHeader
segments={[{ href: backHref, label: t(($) => $.page.title) }]}
leaf={
<>
<h1 className="min-w-0 truncate text-sm font-medium text-foreground">{agent.name}</h1>
{av && presence && (
<span
className={`inline-flex shrink-0 items-center gap-1.5 rounded-md border px-1.5 py-0.5 text-xs ${av.textClass}`}
>
<span className={`h-1.5 w-1.5 rounded-full ${av.dotClass}`} />
{av.label}
</span>
)}
</>
}
actions={
!isArchived && canArchive ? (
<header className="shrink-0 border-b bg-background px-4 pb-5 pt-3 sm:px-6">
<div className="mx-auto max-w-[1440px]">
<div className="flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground">
<AppLink
href={backHref}
className="rounded-sm transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{t(($) => $.page.title)}
</AppLink>
<span aria-hidden="true">/</span>
<span className="truncate text-foreground">{agent.name}</span>
</div>
<div className="mt-4 flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="flex min-w-0 items-start gap-4">
<ActorAvatar
actorType="agent"
actorId={agent.id}
size="2xl"
profileLink={false}
className="ring-1 ring-border"
/>
<div className="min-w-0 pt-0.5">
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5">
<h1 className="min-w-0 text-balance text-xl font-semibold tracking-tight sm:text-2xl">
{agent.name}
</h1>
<AgentPresenceIndicator detail={presence} />
</div>
<p className="mt-1 max-w-2xl text-pretty text-sm leading-6 text-muted-foreground">
{agent.description || t(($) => $.inspector.no_description_placeholder)}
</p>
<div className="mt-3 flex flex-wrap items-center gap-x-4 gap-y-2 text-xs text-muted-foreground">
<span className="inline-flex min-w-0 items-center gap-1.5">
<Bot className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
<span className="truncate">{agent.model || t(($) => $.pickers.model_default)}</span>
</span>
<span className="inline-flex min-w-0 items-center gap-1.5">
<Server className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
<span className="truncate">
{runtime?.name ?? t(($) => $.pickers.runtime_none)}
</span>
</span>
<VisibilityBadge value={agent.visibility} />
<span className="inline-flex items-center gap-1.5">
<Clock3 className="h-3.5 w-3.5" aria-hidden="true" />
{t(($) => $.detail.updated, { when: timeAgo(agent.updated_at) })}
</span>
</div>
</div>
</div>
<div className="flex shrink-0 items-center gap-2 self-end lg:self-start">
{!isArchived && canAssign && (
<Button type="button" size="sm" onClick={onAssign}>
<Plus className="h-4 w-4" aria-hidden="true" />
{t(($) => $.detail.assign_work)}
</Button>
)}
{!isArchived && canArchive ? (
<DropdownMenu>
<DropdownMenuTrigger
render={<Button variant="ghost" size="icon-sm" />}
aria-label={t(($) => $.detail.more_actions_aria)}
>
<MoreHorizontal className="h-4 w-4 text-muted-foreground" />
<MoreHorizontal
className="h-4 w-4 text-muted-foreground"
aria-hidden="true"
/>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-auto">
<DropdownMenuItem
variant="destructive"
onClick={onArchive}
>
<Trash2 className="h-3.5 w-3.5" />
<Trash2 className="h-3.5 w-3.5" aria-hidden="true" />
{t(($) => $.detail.more_archive)}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : null
}
/>
) : null}
</div>
</div>
</div>
</header>
);
}
@@ -435,25 +492,25 @@ function BackHeader({ paths, title }: { paths: string; title: string }) {
function DetailLoadingSkeleton() {
return (
<div className="flex flex-1 min-h-0 flex-col">
<PageHeader className="px-5">
<Skeleton className="h-5 w-48" />
</PageHeader>
<div className="flex flex-1 min-h-0 flex-col gap-3 overflow-y-auto p-3 md:grid md:grid-cols-[320px_minmax(0,1fr)] md:gap-4 md:overflow-hidden md:p-6">
<div className="flex flex-col gap-4 rounded-lg border p-5">
<div className="shrink-0 border-b px-6 pb-5 pt-3">
<Skeleton className="h-4 w-48" />
<div className="mt-4 flex items-start gap-4">
<Skeleton className="h-14 w-14 rounded-full" />
<Skeleton className="h-5 w-40" />
<Skeleton className="h-3 w-full" />
<div className="space-y-2">
<Skeleton className="h-3 w-3/4" />
<Skeleton className="h-3 w-2/3" />
<Skeleton className="h-3 w-1/2" />
<div className="flex-1 space-y-3">
<Skeleton className="h-7 w-64" />
<Skeleton className="h-4 w-full max-w-xl" />
<Skeleton className="h-4 w-full max-w-lg" />
</div>
</div>
<div className="flex flex-col gap-4 rounded-lg border p-6">
<Skeleton className="h-6 w-64" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-5/6" />
<Skeleton className="h-4 w-4/6" />
</div>
<div className="flex flex-1 flex-col p-6">
<Skeleton className="h-9 w-96" />
<div className="mt-6 grid flex-1 gap-6 xl:grid-cols-[minmax(0,1fr)_320px]">
<div className="space-y-5">
<Skeleton className="h-48 w-full" />
<Skeleton className="h-64 w-full" />
</div>
<Skeleton className="h-96 w-full" />
</div>
</div>
</div>

View File

@@ -1,12 +1,16 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import { fireEvent, render, screen } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { Agent, AgentRuntime } from "@multica/core/types";
import { I18nProvider } from "@multica/core/i18n/react";
import enCommon from "../../locales/en/common.json";
import enAgents from "../../locales/en/agents.json";
import {
NavigationProvider,
type NavigationAdapter,
} from "../../navigation";
const TEST_RESOURCES = { en: { common: enCommon, agents: enAgents } };
@@ -15,6 +19,10 @@ const TEST_RESOURCES = { en: { common: enCommon, agents: enAgents } };
// not what each tab does, so we stub the heavy children.
vi.mock("./tabs/activity-tab", () => ({
ActivityTab: () => <div>activity-tab</div>,
AgentPerformanceSummary: () => <div>performance-summary</div>,
}));
vi.mock("./agent-overview-summary", () => ({
AgentOverviewSummary: () => <div>agent-overview-summary</div>,
}));
vi.mock("./tabs/instructions-tab", () => ({
InstructionsTab: () => <div>instructions-tab</div>,
@@ -115,19 +123,44 @@ function renderPane(runtimes: AgentRuntime[]) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
const navigation: NavigationAdapter = {
push: vi.fn(),
replace: vi.fn(),
back: vi.fn(),
pathname: "/acme/agents/agent-1",
searchParams: new URLSearchParams(),
getShareableUrl: (path) => path,
};
return render(
<I18nProvider locale="en" resources={TEST_RESOURCES}>
<QueryClientProvider client={queryClient}>
<AgentOverviewPane
agent={baseAgent}
runtimes={runtimes}
onUpdate={vi.fn().mockResolvedValue(undefined)}
/>
</QueryClientProvider>
<NavigationProvider value={navigation}>
<QueryClientProvider client={queryClient}>
<AgentOverviewPane
agent={baseAgent}
runtime={runtimes[0] ?? null}
owner={null}
presence={{
availability: "online",
workload: "idle",
runningCount: 0,
queuedCount: 0,
capacity: 1,
}}
runtimes={runtimes}
members={[]}
onUpdate={vi.fn().mockResolvedValue(undefined)}
canEdit
/>
</QueryClientProvider>
</NavigationProvider>
</I18nProvider>,
);
}
function openCapabilities() {
fireEvent.click(screen.getByRole("tab", { name: /^Capabilities$/i }));
}
beforeEach(() => {
larkListingRef.current = { installations: [], configured: false };
slackListingRef.current = { installations: [], configured: false };
@@ -145,15 +178,17 @@ describe("AgentOverviewPane MCP tab visibility", () => {
["OpenClaw", "openclaw"],
])("renders the MCP tab when the agent runs on the %s runtime", (_label, provider) => {
renderPane([makeRuntime(provider)]);
expect(screen.getByRole("button", { name: /^MCP$/i })).toBeInTheDocument();
openCapabilities();
expect(screen.getByRole("tab", { name: /^MCP$/i })).toBeInTheDocument();
});
it("hides the MCP tab for providers whose backend does not read mcp_config", () => {
// Saving an MCP config on e.g. Gemini would be a silent no-op at run
// time — that's the bug this hiding logic is meant to prevent.
renderPane([makeRuntime("gemini")]);
openCapabilities();
expect(
screen.queryByRole("button", { name: /^MCP$/i }),
screen.queryByRole("tab", { name: /^MCP$/i }),
).not.toBeInTheDocument();
});
@@ -162,7 +197,8 @@ describe("AgentOverviewPane MCP tab visibility", () => {
// the runtimes query resolving. Hiding the tab would flicker it off and
// then back on, which reads as a bug.
renderPane([]);
expect(screen.getByRole("button", { name: /^MCP$/i })).toBeInTheDocument();
openCapabilities();
expect(screen.getByRole("tab", { name: /^MCP$/i })).toBeInTheDocument();
});
});
@@ -170,8 +206,9 @@ describe("AgentOverviewPane Integrations tab visibility", () => {
it("shows the Integrations tab once the deployment has Lark configured", async () => {
larkListingRef.current = { installations: [], configured: true };
renderPane([makeRuntime("claude")]);
openCapabilities();
expect(
await screen.findByRole("button", { name: /^Integrations$/i }),
await screen.findByRole("tab", { name: /^Integrations$/i }),
).toBeInTheDocument();
});
@@ -180,8 +217,9 @@ describe("AgentOverviewPane Integrations tab visibility", () => {
// a Slack-only deployment was hiding the tab (and its bind entry).
slackListingRef.current = { installations: [], configured: true };
renderPane([makeRuntime("claude")]);
openCapabilities();
expect(
await screen.findByRole("button", { name: /^Integrations$/i }),
await screen.findByRole("tab", { name: /^Integrations$/i }),
).toBeInTheDocument();
});
@@ -189,8 +227,9 @@ describe("AgentOverviewPane Integrations tab visibility", () => {
// Default refs are configured:false; the tab must not appear on
// deployments without either integration, the common case.
renderPane([makeRuntime("claude")]);
openCapabilities();
expect(
screen.queryByRole("button", { name: /^Integrations$/i }),
screen.queryByRole("tab", { name: /^Integrations$/i }),
).not.toBeInTheDocument();
});
});

View File

@@ -1,21 +1,17 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import {
Activity,
Blocks,
BookOpenText,
FileText,
KeyRound,
ListTodo,
Plug,
Router,
Terminal,
Webhook,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { AlertTriangle } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import type { Agent, AgentRuntime } from "@multica/core/types";
import { providerSupportsMcpConfig } from "@multica/core/agents";
import type {
Agent,
AgentRuntime,
MemberWithUser,
} from "@multica/core/types";
import {
providerSupportsMcpConfig,
type AgentPresenceDetail,
} from "@multica/core/agents";
import { useFeatureEnabled } from "@multica/core/config";
import { COMPOSIO_MCP_APPS_FLAG } from "@multica/core/feature-flags";
import { useWorkspaceId } from "@multica/core/hooks";
@@ -31,6 +27,7 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from "@multica/ui/components/ui/alert-dialog";
import { cn } from "@multica/ui/lib/utils";
import { ActivityTab } from "./tabs/activity-tab";
import { InstructionsTab } from "./tabs/instructions-tab";
import { SkillsTab } from "./tabs/skills-tab";
@@ -40,239 +37,370 @@ import { McpConfigTab } from "./tabs/mcp-config-tab";
import { AgentMcpTab } from "./tabs/agent-mcp-tab";
import { IntegrationsTab } from "./tabs/integrations-tab";
import { RuntimeConfigTab } from "./tabs/runtime-config-tab";
import { AgentDetailInspector } from "./agent-detail-inspector";
import { AgentOverviewSummary } from "./agent-overview-summary";
import { ActorIssuesPanel } from "../../common/actor-issues-panel";
import { useT } from "../../i18n";
import { useNavigation } from "../../navigation";
type DetailSection = "overview" | "work" | "capabilities" | "settings";
export type DetailTab =
| "activity"
| "tasks"
| "overview"
| "work"
| "instructions"
| "skills"
| "env"
| "custom_args"
| "mcp_config"
| "composio_mcp"
| "integrations"
| "general"
| "env"
| "custom_args"
| "runtime_config";
const TAB_LABEL_KEY: Record<DetailTab, "activity" | "tasks" | "instructions" | "skills" | "environment" | "custom_args" | "mcp_config" | "composio_mcp" | "integrations" | "runtime_config"> = {
activity: "activity",
tasks: "tasks",
instructions: "instructions",
skills: "skills",
env: "environment",
custom_args: "custom_args",
mcp_config: "mcp_config",
composio_mcp: "composio_mcp",
integrations: "integrations",
runtime_config: "runtime_config",
type SecondaryTab = {
id: DetailTab;
labelKey:
| "instructions"
| "skills"
| "mcp_config"
| "composio_mcp"
| "integrations"
| "general"
| "environment"
| "custom_args"
| "runtime_config";
};
const detailTabs: {
id: DetailTab;
icon: typeof FileText;
}[] = [
{ id: "activity", icon: Activity },
{ id: "tasks", icon: ListTodo },
{ id: "instructions", icon: FileText },
{ id: "skills", icon: BookOpenText },
{ id: "env", icon: KeyRound },
{ id: "custom_args", icon: Terminal },
{ id: "mcp_config", icon: Plug },
{ id: "composio_mcp", icon: Blocks },
{ id: "integrations", icon: Webhook },
{ id: "runtime_config", icon: Router },
const CAPABILITY_TABS: SecondaryTab[] = [
{ id: "instructions", labelKey: "instructions" },
{ id: "skills", labelKey: "skills" },
{ id: "mcp_config", labelKey: "mcp_config" },
{ id: "composio_mcp", labelKey: "composio_mcp" },
{ id: "integrations", labelKey: "integrations" },
];
const SETTINGS_TABS: SecondaryTab[] = [
{ id: "general", labelKey: "general" },
{ id: "env", labelKey: "environment" },
{ id: "custom_args", labelKey: "custom_args" },
{ id: "runtime_config", labelKey: "runtime_config" },
];
const TOP_TABS: { id: DetailSection; labelKey: DetailSection }[] = [
{ id: "overview", labelKey: "overview" },
{ id: "work", labelKey: "work" },
{ id: "capabilities", labelKey: "capabilities" },
{ id: "settings", labelKey: "settings" },
];
const CAPABILITY_IDS = new Set<DetailTab>(
CAPABILITY_TABS.map((tab) => tab.id),
);
const SETTINGS_IDS = new Set<DetailTab>(SETTINGS_TABS.map((tab) => tab.id));
const DETAIL_VIEWS = new Set<DetailTab>([
"overview",
"work",
...CAPABILITY_TABS.map((tab) => tab.id),
...SETTINGS_TABS.map((tab) => tab.id),
]);
function isDetailTab(value: string | null): value is DetailTab {
return value !== null && DETAIL_VIEWS.has(value as DetailTab);
}
function sectionForView(view: DetailTab): DetailSection {
if (view === "overview") return "overview";
if (view === "work") return "work";
if (CAPABILITY_IDS.has(view)) return "capabilities";
return "settings";
}
interface AgentOverviewPaneProps {
agent: Agent;
runtime: AgentRuntime | null;
owner: MemberWithUser | null;
presence: AgentPresenceDetail | null;
runtimes: AgentRuntime[];
members: MemberWithUser[];
onUpdate: (id: string, data: Record<string, unknown>) => Promise<void>;
/**
* The viewer's user id. Gates the creator-only MCP tab — the tab entry is
* only rendered when the viewer is the agent owner (`agent.owner_id`),
* matching the backend's owner-only read/write of the toolkit allowlist
* (MUL-3870). `null` while auth is still loading hides the tab.
*/
currentUserId?: string | null;
/**
* One-shot request from a sibling (the inspector's compact Lark status
* row) to focus a specific tab. Routed through the same `requestTabChange`
* the tab buttons use, so the unsaved-changes guard still fires. The pane
* calls `onNavIntentHandled` to clear it after consuming.
*/
canEdit: boolean;
navIntent?: DetailTab | null;
onNavIntentHandled?: () => void;
}
/**
* Right-pane on the agent detail page:
*
* - Activity (default) — what the agent is doing now / how it's been doing /
* what it just finished. The "watch state" surface.
* - Tasks — assigned/created issues using the shared issue board/list.
* - Instructions / Skills / Env / Custom Args — four editing surfaces.
*
* The previous Settings tab was deleted because every field on it is now
* inline-editable in the inspector (left column) — runtime / model /
* visibility / concurrency via PropRow + Picker, and avatar / name /
* description via popover. Two entry points for the same writes was just
* extra concept count without extra capability.
*
* Activity is the landing tab because most visits to this page are diagnostic
* ("what is this agent doing / why did it fail?"), not configuration tweaks.
*
* **Unsaved-changes guard**: every config tab reports its dirty state up via
* `onDirtyChange`. Switching to another tab while the active tab is dirty
* pops a confirm dialog — without it, switching tabs would silently drop
* unsaved edits because each tab manages its own local state and remounts on
* tab change.
* Agent workbench organised around user intent instead of backend fields.
* Overview answers "what is happening now?", Work owns the issue surface,
* Capabilities describes what the agent can do, and Settings describes how
* it runs. The lower-level editors stay intact so the reorganisation does not
* alter persistence or permission semantics.
*/
export function AgentOverviewPane({
agent,
runtime,
owner,
presence,
runtimes,
members,
onUpdate,
currentUserId,
canEdit,
navIntent,
onNavIntentHandled,
}: AgentOverviewPaneProps) {
const { t } = useT("agents");
const wsId = useWorkspaceId();
const composioMCPAppsEnabled = useFeatureEnabled(COMPOSIO_MCP_APPS_FLAG, false);
const [activeTab, setActiveTab] = useState<DetailTab>("activity");
const navigation = useNavigation();
const urlView = navigation.searchParams.get("view");
const composioMCPAppsEnabled = useFeatureEnabled(
COMPOSIO_MCP_APPS_FLAG,
false,
);
const [activeView, setActiveView] = useState<DetailTab>(() =>
isDetailTab(urlView) ? urlView : "overview",
);
const [activeDirty, setActiveDirty] = useState(false);
// Holds the destination when a tab change is intercepted by the dirty
// guard. Null means no pending change. The AlertDialog reads non-null as
// "open".
const [pendingTab, setPendingTab] = useState<DetailTab | null>(null);
const [pendingView, setPendingView] = useState<DetailTab | null>(null);
const lastUrlViewRef = useRef(urlView);
const runtime = agent.runtime_id
? runtimes.find((r) => r.id === agent.runtime_id) ?? null
: null;
// Cached per-workspace and shared with the inspector's bind button, so this
// is at most one extra GET per workspace. We only read `configured` to
// decide whether the Integrations tab is worth showing at all.
const { data: larkListing } = useQuery({
...larkInstallationsOptions(wsId),
enabled: !!wsId,
});
const larkConfigured = larkListing?.configured === true;
const { data: slackListing } = useQuery({
...slackInstallationsOptions(wsId),
enabled: !!wsId,
});
const slackConfigured = slackListing?.configured === true;
// The Integrations tab appears once EITHER channel is wired on the
// deployment, so a Slack-only deployment (no Lark) still surfaces it.
const integrationsConfigured = larkConfigured || slackConfigured;
// The MCP tab is only shown when the agent's runtime backend actually
// consumes mcp_config — see providerSupportsMcpConfig. We default to
// showing it when the runtime row hasn't loaded yet so a slow fetch
// can't transiently flicker the tab off and then on.
//
// The Integrations tab appears once the deployment has Lark OR Slack wired
// (configured). Unlike MCP we default to HIDING while the listing loads:
// deployments without either channel are the common case, so flashing the
// tab on then off would be the worse flicker.
//
// The Runtime Config tab is openclaw-only today (gateway mode lives there,
// issue #3260). Other providers' runtime_config is freeform JSONB that no
// backend currently reads, so surfacing the tab would let users save values
// their runtime ignores — same anti-footgun rationale as the MCP gate.
const visibleTabs = useMemo(() => {
const showMcp = runtime ? providerSupportsMcpConfig(runtime.provider) : true;
const showRuntimeConfig = runtime ? runtime.provider === "openclaw" : false;
// The Composio MCP tab is creator-only: it edits the agent owner's own
// toolkit allowlist, which the backend reads/writes for the owner alone
// (redacted + write-dropped for everyone else — MUL-3870 / MUL-3869).
// Hide the entry entirely for non-owners, and while auth is still loading.
const integrationsConfigured =
larkListing?.configured === true || slackListing?.configured === true;
const visibleCapabilityTabs = useMemo(() => {
const showMcp = runtime
? providerSupportsMcpConfig(runtime.provider)
: true;
const showComposioMcp =
composioMCPAppsEnabled &&
!!currentUserId &&
!!agent.owner_id &&
agent.owner_id === currentUserId;
return detailTabs.filter((tab) => {
return CAPABILITY_TABS.filter((tab) => {
if (tab.id === "mcp_config") return showMcp;
if (tab.id === "composio_mcp") return showComposioMcp;
if (tab.id === "integrations") return integrationsConfigured;
if (tab.id === "runtime_config") return showRuntimeConfig;
return true;
});
}, [runtime, integrationsConfigured, composioMCPAppsEnabled, currentUserId, agent.owner_id]);
}, [
agent.owner_id,
composioMCPAppsEnabled,
currentUserId,
integrationsConfigured,
runtime,
]);
// If the active tab disappears (e.g. user just switched the agent's
// runtime to one that doesn't read mcp_config), fall back to Activity
// for this render so the pane is never empty. The user's stored
// activeTab is left alone — switching back to a supporting runtime
// brings their selection back.
const effectiveTab: DetailTab = visibleTabs.some((tab) => tab.id === activeTab)
? activeTab
: "activity";
const visibleSettingsTabs = useMemo(
() =>
SETTINGS_TABS.filter(
(tab) => tab.id !== "runtime_config" || runtime?.provider === "openclaw",
),
[runtime?.provider],
);
const requestTabChange = (next: DetailTab) => {
if (next === activeTab) return;
if (activeDirty) {
setPendingTab(next);
const visibleViews = useMemo(
() =>
new Set<DetailTab>([
"overview",
"work",
...visibleCapabilityTabs.map((tab) => tab.id),
...visibleSettingsTabs.map((tab) => tab.id),
]),
[visibleCapabilityTabs, visibleSettingsTabs],
);
const effectiveView = visibleViews.has(activeView) ? activeView : "overview";
const activeSection = sectionForView(effectiveView);
const commitView = useCallback(
(next: DetailTab) => {
setActiveView(next);
const params = new URLSearchParams(navigation.searchParams);
if (next === "overview") params.delete("view");
else params.set("view", next);
const query = params.toString();
navigation.replace(`${navigation.pathname}${query ? `?${query}` : ""}`);
},
[navigation],
);
const requestView = useCallback(
(next: DetailTab) => {
if (next === effectiveView) return;
if (activeDirty) {
setPendingView(next);
return;
}
commitView(next);
},
[activeDirty, commitView, effectiveView],
);
const requestSection = (section: DetailSection) => {
if (section === "overview" || section === "work") {
requestView(section);
return;
}
setActiveTab(next);
};
const commitTabChange = () => {
if (pendingTab) {
setActiveTab(pendingTab);
// The new tab mounts fresh; its effect will report its own dirty state.
// We pre-clear so the guard can't trip from stale state on the way in.
setActiveDirty(false);
setPendingTab(null);
if (section === "capabilities") {
const current = CAPABILITY_IDS.has(effectiveView)
? effectiveView
: visibleCapabilityTabs[0]?.id;
if (current) requestView(current);
return;
}
const current = SETTINGS_IDS.has(effectiveView)
? effectiveView
: visibleSettingsTabs[0]?.id;
if (current) requestView(current);
};
// Consume a one-shot tab-focus request from a sibling. Routing through
// `requestTabChange` (rather than `setActiveTab`) keeps the unsaved-changes
// guard honored even when the request originates outside the tab strip. The
// effect body is a no-op while `navIntent` is null, so the unstable
// `requestTabChange`/`onNavIntentHandled` identities can't loop it.
const commitViewChange = () => {
if (!pendingView) return;
commitView(pendingView);
setActiveDirty(false);
setPendingView(null);
};
useEffect(() => {
if (urlView === lastUrlViewRef.current) return;
lastUrlViewRef.current = urlView;
if (urlView === null) {
setActiveView("overview");
return;
}
if (isDetailTab(urlView) && visibleViews.has(urlView)) {
setActiveView(urlView);
}
}, [urlView, visibleViews]);
useEffect(() => {
if (navIntent == null) return;
requestTabChange(navIntent);
if (visibleViews.has(navIntent)) requestView(navIntent);
onNavIntentHandled?.();
}, [navIntent, requestTabChange, onNavIntentHandled]);
}, [navIntent, onNavIntentHandled, requestView, visibleViews]);
const secondaryTabs =
activeSection === "capabilities"
? visibleCapabilityTabs
: activeSection === "settings"
? visibleSettingsTabs
: [];
const needsAttention =
presence !== null &&
presence.availability !== "online" &&
presence.queuedCount > 0;
return (
// On mobile the parent stacks the inspector and overview and scrolls the
// page itself, so this pane has no inherited height. `min-h-[60vh]` keeps
// the tab content area usably tall when content is short; `md:` restores
// the grid-driven full-height behavior on tablet and up.
<div className="flex min-h-[60vh] flex-col overflow-hidden rounded-lg border bg-background md:h-full md:min-h-0">
<div className="flex shrink-0 items-center gap-0 overflow-x-auto border-b px-2 md:px-4">
{visibleTabs.map((tab) => (
<button
key={tab.id}
type="button"
onClick={() => requestTabChange(tab.id)}
className={`flex shrink-0 items-center gap-1.5 whitespace-nowrap border-b-2 px-3 py-2.5 text-xs font-medium transition-colors ${
effectiveTab === tab.id
? "border-foreground text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground"
}`}
>
<tab.icon className="h-3.5 w-3.5" />
{t(($) => $.tabs[TAB_LABEL_KEY[tab.id]])}
</button>
))}
<div className="flex min-h-0 flex-1 flex-col bg-background">
<div
className="shrink-0 overflow-x-auto border-b px-4 sm:px-6"
role="tablist"
aria-label={t(($) => $.tabs.page_navigation_aria)}
>
<div className="mx-auto flex max-w-[1440px] items-center gap-6">
{TOP_TABS.map((tab) => (
<button
key={tab.id}
type="button"
role="tab"
aria-selected={activeSection === tab.id}
onClick={() => requestSection(tab.id)}
className={cn(
"relative shrink-0 py-3 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring",
activeSection === tab.id
? "text-foreground after:absolute after:inset-x-0 after:bottom-0 after:h-0.5 after:bg-foreground"
: "text-muted-foreground hover:text-foreground",
)}
>
{t(($) => $.tabs[tab.labelKey])}
</button>
))}
</div>
</div>
<div className="flex-1 min-h-0 overflow-y-auto">
{effectiveTab === "activity" && <ActivityTab agent={agent} />}
{effectiveTab === "tasks" && (
<div className="flex h-full min-h-[520px] flex-col">
{secondaryTabs.length > 0 && (
<div className="shrink-0 border-b bg-muted/20 px-4 py-2 sm:px-6">
<div
className="mx-auto flex max-w-[1440px] items-center gap-1 overflow-x-auto"
role="tablist"
aria-label={t(($) => $.tabs.section_navigation_aria)}
>
{secondaryTabs.map((tab) => (
<button
key={tab.id}
type="button"
role="tab"
aria-selected={effectiveView === tab.id}
onClick={() => requestView(tab.id)}
className={cn(
"shrink-0 rounded-md px-3 py-1.5 text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
effectiveView === tab.id
? "bg-background text-foreground shadow-xs ring-1 ring-border"
: "text-muted-foreground hover:bg-background/70 hover:text-foreground",
)}
>
{t(($) => $.tabs[tab.labelKey])}
</button>
))}
</div>
</div>
)}
<div className="min-h-0 flex-1 overflow-y-auto">
{effectiveView === "overview" && (
<div className="mx-auto max-w-[1440px] p-4 sm:p-6">
{needsAttention && (
<div
role="status"
className="mb-5 flex items-start gap-3 rounded-lg border border-warning/40 bg-warning/5 px-4 py-3"
>
<AlertTriangle
className="mt-0.5 h-4 w-4 shrink-0 text-warning"
aria-hidden="true"
/>
<div className="min-w-0">
<p className="text-sm font-medium">
{t(($) => $.overview.attention_title)}
</p>
<p className="mt-0.5 text-xs text-muted-foreground">
{t(($) => $.overview.attention_queued, {
count: presence.queuedCount,
})}
</p>
</div>
</div>
)}
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_320px]">
<ActivityTab agent={agent} showPerformance={false} />
<AgentOverviewSummary
agent={agent}
runtime={runtime}
owner={owner}
/>
</div>
</div>
)}
{effectiveView === "work" && (
<div className="flex min-h-[620px] flex-col">
<ActorIssuesPanel actorType="agent" actorId={agent.id} />
</div>
)}
{effectiveTab === "instructions" && (
{effectiveView === "instructions" && (
<TabContent>
<InstructionsTab
agent={agent}
@@ -281,20 +409,51 @@ export function AgentOverviewPane({
/>
</TabContent>
)}
{effectiveTab === "skills" && (
{effectiveView === "skills" && (
<TabContent>
<SkillsTab agent={agent} />
</TabContent>
)}
{effectiveTab === "env" && (
{effectiveView === "mcp_config" && (
<TabContent>
<EnvTab
<McpConfigTab
agent={agent}
onSave={(updates) => onUpdate(agent.id, updates)}
onDirtyChange={setActiveDirty}
/>
</TabContent>
)}
{effectiveTab === "custom_args" && (
{effectiveView === "composio_mcp" && (
<TabContent>
<AgentMcpTab agent={agent} />
</TabContent>
)}
{effectiveView === "integrations" && (
<TabContent>
<IntegrationsTab agent={agent} />
</TabContent>
)}
{effectiveView === "general" && (
<TabContent wide>
<AgentDetailInspector
agent={agent}
runtime={runtime}
owner={owner}
runtimes={runtimes}
members={members}
currentUserId={currentUserId ?? null}
canEdit={canEdit}
onUpdate={onUpdate}
/>
</TabContent>
)}
{effectiveView === "env" && (
<TabContent>
<EnvTab agent={agent} onDirtyChange={setActiveDirty} />
</TabContent>
)}
{effectiveView === "custom_args" && (
<TabContent>
<CustomArgsTab
agent={agent}
@@ -304,26 +463,7 @@ export function AgentOverviewPane({
/>
</TabContent>
)}
{effectiveTab === "mcp_config" && (
<TabContent>
<McpConfigTab
agent={agent}
onSave={(updates) => onUpdate(agent.id, updates)}
onDirtyChange={setActiveDirty}
/>
</TabContent>
)}
{effectiveTab === "composio_mcp" && (
<TabContent>
<AgentMcpTab agent={agent} />
</TabContent>
)}
{effectiveTab === "integrations" && (
<TabContent>
<IntegrationsTab agent={agent} />
</TabContent>
)}
{effectiveTab === "runtime_config" && (
{effectiveView === "runtime_config" && (
<TabContent>
<RuntimeConfigTab
agent={agent}
@@ -334,25 +474,29 @@ export function AgentOverviewPane({
)}
</div>
{pendingTab !== null && (
{pendingView !== null && (
<AlertDialog
open
onOpenChange={(v) => {
if (!v) setPendingTab(null);
onOpenChange={(open) => {
if (!open) setPendingView(null);
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t(($) => $.tabs.discard_dialog_title)}</AlertDialogTitle>
<AlertDialogTitle>
{t(($) => $.tabs.discard_dialog_title)}
</AlertDialogTitle>
<AlertDialogDescription>
{t(($) => $.tabs.discard_dialog_description)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t(($) => $.tabs.discard_keep)}</AlertDialogCancel>
<AlertDialogCancel>
{t(($) => $.tabs.discard_keep)}
</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={commitTabChange}
onClick={commitViewChange}
>
{t(($) => $.tabs.discard_confirm)}
</AlertDialogAction>
@@ -364,14 +508,21 @@ export function AgentOverviewPane({
);
}
// Padded, full-width container shared by every config tab. `h-full flex
// flex-col` lets a tab opt into "fill the viewport" by giving its root
// element `flex-1 min-h-0` (Instructions does this so the editor expands
// instead of pushing the Save row off-screen). Tabs that don't opt in
// behave as natural-height blocks; long content (e.g. Settings, long Skills
// list) still scrolls via the parent's overflow-y-auto.
function TabContent({ children }: { children: React.ReactNode }) {
function TabContent({
children,
wide = false,
}: {
children: React.ReactNode;
wide?: boolean;
}) {
return (
<div className="flex h-full flex-col p-4 md:p-6">{children}</div>
<div
className={cn(
"mx-auto flex min-h-full flex-col p-4 sm:p-6",
wide ? "max-w-[1200px]" : "max-w-5xl",
)}
>
{children}
</div>
);
}

View File

@@ -0,0 +1,130 @@
"use client";
import { Bot, Server } from "lucide-react";
import type {
Agent,
AgentRuntime,
MemberWithUser,
} from "@multica/core/types";
import { ActorAvatar } from "../../common/actor-avatar";
import { useT } from "../../i18n";
import { VisibilityBadge } from "./visibility-badge";
import { AgentPerformanceSummary } from "./tabs/activity-tab";
interface AgentOverviewSummaryProps {
agent: Agent;
runtime: AgentRuntime | null;
owner: MemberWithUser | null;
}
/**
* Read-only context for the workbench Overview. Editing lives under Settings;
* keeping this surface non-interactive lets users scan identity, execution,
* and capability context without mistaking every value for a control.
*/
export function AgentOverviewSummary({
agent,
runtime,
owner,
}: AgentOverviewSummaryProps) {
const { t } = useT("agents");
const runtimeOnline = runtime?.status === "online";
return (
<aside className="self-start rounded-lg border bg-muted/10 p-5 xl:sticky xl:top-6">
<section>
<h2 className="text-sm font-semibold">
{t(($) => $.overview.agent_context)}
</h2>
<dl className="mt-4 space-y-3 text-xs">
{owner && (
<SummaryRow label={t(($) => $.inspector.prop_owner)}>
<span className="flex min-w-0 items-center gap-1.5">
<ActorAvatar
actorType="member"
actorId={owner.user_id}
size="xs"
/>
<span className="truncate text-foreground">{owner.name}</span>
</span>
</SummaryRow>
)}
<SummaryRow label={t(($) => $.overview.access)}>
<VisibilityBadge value={agent.visibility} />
</SummaryRow>
<SummaryRow label={t(($) => $.inspector.prop_runtime)}>
<span className="flex min-w-0 items-center gap-1.5 text-foreground">
<span
className={`h-1.5 w-1.5 shrink-0 rounded-full ${
runtimeOnline ? "bg-success" : "bg-muted-foreground/40"
}`}
aria-hidden="true"
/>
<Server className="h-3 w-3 shrink-0 text-muted-foreground" aria-hidden="true" />
<span className="truncate">
{runtime?.name ?? t(($) => $.pickers.runtime_none)}
</span>
</span>
</SummaryRow>
<SummaryRow label={t(($) => $.inspector.prop_model)}>
<span className="flex min-w-0 items-center gap-1.5 text-foreground">
<Bot className="h-3 w-3 shrink-0 text-muted-foreground" aria-hidden="true" />
<span className="truncate">
{agent.model || t(($) => $.pickers.model_default)}
</span>
</span>
</SummaryRow>
<SummaryRow label={t(($) => $.inspector.prop_concurrency)}>
<span className="font-mono tabular-nums text-foreground">
{agent.max_concurrent_tasks}
</span>
</SummaryRow>
</dl>
</section>
<section className="mt-5 border-t pt-5">
<div className="flex items-center justify-between gap-3">
<h2 className="text-sm font-semibold">
{t(($) => $.inspector.section_skills)}
</h2>
<span className="font-mono text-[11px] tabular-nums text-muted-foreground">
{agent.skills.length}
</span>
</div>
{agent.skills.length > 0 ? (
<div className="mt-3 flex flex-wrap gap-1.5">
{agent.skills.map((skill) => (
<span
key={skill.id}
className="max-w-full truncate rounded-md border bg-background px-2 py-1 text-[11px] text-muted-foreground"
>
{skill.name}
</span>
))}
</div>
) : (
<p className="mt-3 text-xs text-muted-foreground">
{t(($) => $.tab_body.skills.empty_title)}
</p>
)}
</section>
<AgentPerformanceSummary agent={agent} />
</aside>
);
}
function SummaryRow({
label,
children,
}: {
label: string;
children: React.ReactNode;
}) {
return (
<div className="grid grid-cols-[88px_minmax(0,1fr)] items-center gap-3">
<dt className="text-muted-foreground">{label}</dt>
<dd className="min-w-0">{children}</dd>
</div>
);
}

View File

@@ -51,6 +51,7 @@ const RECENT_PAGE = 20;
interface ActivityTabProps {
agent: Agent;
showPerformance?: boolean;
}
/**
@@ -66,7 +67,7 @@ interface ActivityTabProps {
* the workspace 7d activity buckets for the trend), so opening this tab
* adds no extra fetches once the page is hydrated.
*/
export function ActivityTab({ agent }: ActivityTabProps) {
export function ActivityTab({ agent, showPerformance = true }: ActivityTabProps) {
const wsId = useWorkspaceId();
const { data: snapshot = [] } = useQuery(agentTaskSnapshotOptions(wsId));
@@ -83,14 +84,27 @@ export function ActivityTab({ agent }: ActivityTabProps) {
const isWorkflowTask = (t: AgentTask) => !t.chat_session_id;
const activeTasks = useMemo(() => {
return snapshot.filter(
(t) =>
t.agent_id === agent.id &&
isWorkflowTask(t) &&
(t.status === "running" ||
t.status === "queued" ||
t.status === "dispatched"),
);
const statusRank: Partial<Record<AgentTask["status"], number>> = {
running: 0,
dispatched: 1,
waiting_local_directory: 2,
queued: 3,
};
return snapshot
.filter(
(t) =>
t.agent_id === agent.id &&
isWorkflowTask(t) &&
(t.status === "running" ||
t.status === "queued" ||
t.status === "dispatched" ||
t.status === "waiting_local_directory"),
)
.sort(
(a, b) =>
(statusRank[a.status] ?? 99) - (statusRank[b.status] ?? 99) ||
new Date(a.created_at).getTime() - new Date(b.created_at).getTime(),
);
}, [snapshot, agent.id]);
// Most recent terminal tasks. Includes cancelled — users searching
@@ -151,9 +165,11 @@ export function ActivityTab({ agent }: ActivityTabProps) {
}, [issueQueries, issueIds]);
return (
<div className="flex flex-col gap-4 p-6">
<div className="flex min-w-0 flex-col gap-6">
<NowSection tasks={activeTasks} issueMap={issueMap} agent={agent} />
<Last30dSection activity={activity} avgDurationMs={avgDurationMs} />
{showPerformance && (
<Last30dSection activity={activity} avgDurationMs={avgDurationMs} />
)}
<RecentWorkSection
tasks={recentTasks}
totalCount={recentTasksAll.length}
@@ -168,6 +184,96 @@ export function ActivityTab({ agent }: ActivityTabProps) {
);
}
/** Compact performance context for the Overview sidebar. Kept separate from
* the work list so metrics never outrank current tasks or failures. */
export function AgentPerformanceSummary({ agent }: { agent: Agent }) {
const { t } = useT("agents");
const wsId = useWorkspaceId();
const { data: agentTasks = [] } = useQuery(
agentTasksOptions(wsId, agent.id),
);
const { byAgent: activityMap } = useWorkspaceActivityMap(wsId);
const activity = activityMap.get(agent.id);
const summary = summarizeActivityWindow(activity, 30);
const avgDurationMs = useMemo(
() => deriveAvgDurationLast30d(agentTasks, Date.now()),
[agentTasks],
);
const successPct =
summary.totalRuns > 0
? Math.round(
((summary.totalRuns - summary.totalFailed) / summary.totalRuns) *
100,
)
: 100;
return (
<section className="border-t pt-5">
<h2 className="text-sm font-semibold">
{t(($) => $.tab_body.activity.section_last_30d)}
</h2>
{summary.totalRuns === 0 ? (
<p className="mt-3 text-xs text-muted-foreground">
{t(($) => $.tab_body.activity.empty_30d)}
</p>
) : (
<>
<div className="mt-4 grid grid-cols-2 gap-x-4 gap-y-3">
<Metric
value={String(summary.totalRuns)}
label={t(($) => $.tab_body.activity.runs, {
count: summary.totalRuns,
})}
/>
<Metric
value={`${successPct}%`}
label={t(($) => $.tab_body.activity.success_label)}
/>
<Metric
value={avgDurationMs > 0 ? formatDurationMs(avgDurationMs) : "—"}
label={t(($) => $.tab_body.activity.avg_duration_label)}
/>
<Metric
value={String(summary.totalFailed)}
label={t(($) => $.tab_body.activity.failed_label)}
destructive={summary.totalFailed > 0}
/>
</div>
<Sparkline
buckets={summary.buckets}
width={250}
height={36}
className="mt-4 h-9 w-full"
/>
</>
)}
</section>
);
}
function Metric({
value,
label,
destructive = false,
}: {
value: string;
label: string;
destructive?: boolean;
}) {
return (
<div className="min-w-0">
<div
className={`text-lg font-semibold tabular-nums ${
destructive ? "text-destructive" : "text-foreground"
}`}
>
{value}
</div>
<div className="truncate text-[11px] text-muted-foreground">{label}</div>
</div>
);
}
function NowSection({
tasks,
issueMap,
@@ -326,7 +432,13 @@ function TaskList({
agent: Agent;
}) {
return (
<div className="space-y-1.5">
<div
className={
timeMode === "completed"
? "overflow-hidden rounded-lg border divide-y"
: "space-y-2"
}
>
{tasks.map((task) => (
<TaskRow
key={task.id}
@@ -442,16 +554,20 @@ function TaskRow({
if (dur > 0) durationText = formatDurationMs(dur);
}
const rowClass = `group flex items-center gap-3 rounded-md border px-3 py-2.5 ${
isRunning ? "border-brand/40 bg-brand/5" : ""
}`;
const rowClass =
timeMode === "completed"
? "group flex items-center gap-3 px-3 py-3 transition-colors hover:bg-muted/30"
: `group flex items-center gap-3 rounded-md border px-3 py-3 ${
isRunning ? "border-brand/40 bg-brand/5" : ""
}`;
return (
<div className={rowClass}>
<Icon
className={`h-4 w-4 shrink-0 ${cfg.color} ${
isRunning ? "animate-spin" : ""
isRunning ? "animate-spin motion-reduce:animate-none" : ""
}`}
aria-hidden="true"
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
@@ -500,6 +616,10 @@ function TaskRow({
)}
</div>
<div className="mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs text-muted-foreground">
<span className={cfg.color}>
{taskStatusLabel(task.status, t)}
</span>
<Sep />
<span>{timeText}</span>
{durationText && (
<>
@@ -528,7 +648,7 @@ function TaskRow({
aria-label={t(($) => $.tab_body.activity.open_issue_aria)}
className="flex items-center justify-center rounded p-1 text-muted-foreground hover:text-foreground hover:bg-accent/50 transition-colors"
>
<ArrowUpRight className="h-3.5 w-3.5" />
<ArrowUpRight className="h-3.5 w-3.5" aria-hidden="true" />
</TooltipTrigger>
<TooltipContent>{t(($) => $.tab_body.activity.open_issue_tooltip)}</TooltipContent>
</Tooltip>
@@ -554,7 +674,7 @@ function TaskRow({
}
className="flex items-center justify-center rounded p-1 text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive disabled:cursor-not-allowed disabled:opacity-50"
>
<X className="h-3.5 w-3.5" />
<X className="h-3.5 w-3.5" aria-hidden="true" />
</TooltipTrigger>
<TooltipContent>
{cancelling ? t(($) => $.tab_body.activity.cancelling_tooltip) : t(($) => $.tab_body.activity.cancel_task_tooltip)}
@@ -576,11 +696,11 @@ function Section({
children: ReactNode;
}) {
return (
<section className="flex flex-col gap-3 rounded-lg border bg-background p-5">
<section className="flex flex-col gap-3 border-b pb-6 last:border-b-0 last:pb-0">
<div className="flex items-baseline gap-2">
<h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
<h2 className="text-sm font-semibold text-foreground">
{title}
</h3>
</h2>
<span className="text-[11px] text-muted-foreground/70">{subtitle}</span>
</div>
{children}
@@ -602,6 +722,25 @@ function Sep() {
type AgentsT = ReturnType<typeof useT<"agents">>["t"];
type TimeAgoFn = (dateStr: string) => string;
function taskStatusLabel(status: AgentTask["status"], t: AgentsT): string {
switch (status) {
case "queued":
return t(($) => $.tab_body.activity.status.queued);
case "dispatched":
return t(($) => $.tab_body.activity.status.dispatched);
case "waiting_local_directory":
return t(($) => $.tab_body.activity.status.waiting_local_directory);
case "running":
return t(($) => $.tab_body.activity.status.running);
case "completed":
return t(($) => $.tab_body.activity.status.completed);
case "failed":
return t(($) => $.tab_body.activity.status.failed);
case "cancelled":
return t(($) => $.tab_body.activity.status.cancelled);
}
}
function activeTaskTimeText(task: AgentTask, t: AgentsT, timeAgo: TimeAgoFn): string {
if (task.status === "running" && task.started_at) {
return t(($) => $.tab_body.activity.started_prefix, { when: timeAgo(task.started_at) });

View File

@@ -143,9 +143,15 @@
"agent_archived_toast": "Agent archived",
"archive_failed_toast": "Failed to archive agent",
"agent_restored_toast": "Agent restored",
"restore_failed_toast": "Failed to restore agent"
"restore_failed_toast": "Failed to restore agent",
"assign_work": "Assign work",
"more_actions_aria": "Agent actions",
"updated": "Updated {{when}}"
},
"inspector": {
"section_profile": "Profile",
"section_execution": "Execution",
"section_access": "Access",
"section_properties": "Properties",
"section_details": "Details",
"section_skills": "Skills",
@@ -170,6 +176,13 @@
"save": "Save",
"cancel": "Cancel"
},
"overview": {
"agent_context": "Agent",
"access": "Access",
"attention_title": "Tasks need attention",
"attention_queued_one": "{{count}} task is queued while the runtime is unavailable.",
"attention_queued_other": "{{count}} tasks are queued while the runtime is unavailable."
},
"skill_attach": {
"trigger_aria": "Attach a workspace skill",
"trigger_label": "Attach"
@@ -211,6 +224,13 @@
"clear_full": "Clear selection (use provider default)"
},
"tabs": {
"overview": "Overview",
"work": "Work",
"capabilities": "Capabilities",
"settings": "Settings",
"general": "General",
"page_navigation_aria": "Agent page",
"section_navigation_aria": "Agent section",
"activity": "Activity",
"tasks": "Tasks",
"instructions": "Instructions",
@@ -406,6 +426,15 @@
"members_note": "Only workspace owners and admins can connect an agent to an external chat platform. You can view connected bots in Settings → Integrations."
},
"activity": {
"status": {
"queued": "Queued",
"dispatched": "Dispatched",
"waiting_local_directory": "Waiting for directory",
"running": "Running",
"completed": "Succeeded",
"failed": "Failed",
"cancelled": "Cancelled"
},
"section_now": "Now",
"section_last_30d": "Last 30 days",
"section_recent": "Recent work",
@@ -423,8 +452,11 @@
"runs_one": "run",
"runs_other": "runs",
"success_pct": "{{percent}}% success",
"success_label": "succeeded",
"avg_duration": "avg {{value}}",
"avg_duration_label": "average duration",
"failed_count": "{{count}} failed",
"failed_label": "failed",
"source_issue": "Issue",
"source_chat": "Chat",
"source_autopilot": "Autopilot",

View File

@@ -127,9 +127,15 @@
"agent_archived_toast": "エージェントをアーカイブしました",
"archive_failed_toast": "エージェントをアーカイブできませんでした",
"agent_restored_toast": "エージェントを復元しました",
"restore_failed_toast": "エージェントを復元できませんでした"
"restore_failed_toast": "エージェントを復元できませんでした",
"assign_work": "作業を割り当てる",
"more_actions_aria": "エージェントの操作",
"updated": "{{when}}に更新"
},
"inspector": {
"section_profile": "プロフィール",
"section_execution": "実行設定",
"section_access": "アクセス",
"section_properties": "プロパティ",
"section_details": "詳細",
"section_skills": "スキル",
@@ -154,6 +160,12 @@
"save": "保存",
"cancel": "キャンセル"
},
"overview": {
"agent_context": "エージェント",
"access": "アクセス",
"attention_title": "対応が必要な task",
"attention_queued_other": "ランタイムが利用できないため、{{count}}件の task が待機中です。"
},
"skill_attach": {
"trigger_aria": "ワークスペースのスキルを追加",
"trigger_label": "追加"
@@ -195,6 +207,13 @@
"clear_full": "選択をクリア(プロバイダーのデフォルトを使用)"
},
"tabs": {
"overview": "概要",
"work": "作業",
"capabilities": "機能",
"settings": "設定",
"general": "一般",
"page_navigation_aria": "エージェントページ",
"section_navigation_aria": "エージェントセクション",
"activity": "アクティビティ",
"tasks": "タスク",
"instructions": "指示",
@@ -387,6 +406,15 @@
"members_note": "エージェントを外部チャットプラットフォームに接続できるのはワークスペースのオーナーと管理者のみです。接続済みの Bot は「設定 → 連携」で確認できます。"
},
"activity": {
"status": {
"queued": "待機中",
"dispatched": "送信済み",
"waiting_local_directory": "ディレクトリ待機中",
"running": "実行中",
"completed": "成功",
"failed": "失敗",
"cancelled": "キャンセル済み"
},
"section_now": "現在",
"section_last_30d": "過去30日",
"section_recent": "最近の作業",
@@ -402,8 +430,11 @@
"show_more": "もっと見る →",
"runs_other": "回実行",
"success_pct": "成功率 {{percent}}%",
"success_label": "成功",
"avg_duration": "平均 {{value}}",
"avg_duration_label": "平均時間",
"failed_count": "{{count}} 回失敗",
"failed_label": "失敗",
"source_issue": "イシュー",
"source_chat": "チャット",
"source_autopilot": "オートパイロット",

View File

@@ -135,9 +135,15 @@
"agent_archived_toast": "에이전트를 보관했습니다",
"archive_failed_toast": "에이전트를 보관하지 못했습니다",
"agent_restored_toast": "에이전트를 복원했습니다",
"restore_failed_toast": "에이전트를 복원하지 못했습니다"
"restore_failed_toast": "에이전트를 복원하지 못했습니다",
"assign_work": "작업 할당",
"more_actions_aria": "에이전트 작업",
"updated": "{{when}} 업데이트됨"
},
"inspector": {
"section_profile": "프로필",
"section_execution": "실행 설정",
"section_access": "접근 권한",
"section_properties": "속성",
"section_details": "세부 정보",
"section_skills": "스킬",
@@ -162,6 +168,12 @@
"save": "저장",
"cancel": "취소"
},
"overview": {
"agent_context": "에이전트",
"access": "접근 권한",
"attention_title": "확인이 필요한 task",
"attention_queued_other": "런타임을 사용할 수 없어 {{count}}개의 task가 대기 중입니다."
},
"skill_attach": {
"trigger_aria": "워크스페이스 스킬 연결",
"trigger_label": "연결"
@@ -203,6 +215,13 @@
"clear_full": "선택 지우기(제공자 기본값 사용)"
},
"tabs": {
"overview": "개요",
"work": "작업",
"capabilities": "기능",
"settings": "설정",
"general": "일반",
"page_navigation_aria": "에이전트 페이지",
"section_navigation_aria": "에이전트 섹션",
"activity": "활동",
"tasks": "작업",
"instructions": "지침",
@@ -395,6 +414,15 @@
"members_note": "에이전트를 외부 채팅 플랫폼에 연결할 수 있는 사람은 워크스페이스 소유자와 관리자뿐입니다. 연결된 봇은 설정 → 연동에서 확인할 수 있습니다."
},
"activity": {
"status": {
"queued": "대기 중",
"dispatched": "전달됨",
"waiting_local_directory": "디렉터리 대기 중",
"running": "실행 중",
"completed": "성공",
"failed": "실패",
"cancelled": "취소됨"
},
"section_now": "현재",
"section_last_30d": "최근 30일",
"section_recent": "최근 작업",
@@ -410,8 +438,11 @@
"show_more": "더 보기 →",
"runs_other": "회 실행",
"success_pct": "성공률 {{percent}}%",
"success_label": "성공",
"avg_duration": "평균 {{value}}",
"avg_duration_label": "평균 소요 시간",
"failed_count": "{{count}}회 실패",
"failed_label": "실패",
"source_issue": "이슈",
"source_chat": "채팅",
"source_autopilot": "오토파일럿",

View File

@@ -135,9 +135,15 @@
"agent_archived_toast": "已归档智能体",
"archive_failed_toast": "归档智能体失败",
"agent_restored_toast": "已恢复智能体",
"restore_failed_toast": "恢复智能体失败"
"restore_failed_toast": "恢复智能体失败",
"assign_work": "分配工作",
"more_actions_aria": "智能体操作",
"updated": "{{when}}更新"
},
"inspector": {
"section_profile": "资料",
"section_execution": "执行配置",
"section_access": "访问权限",
"section_properties": "属性",
"section_details": "详情",
"section_skills": "Skills",
@@ -162,6 +168,12 @@
"save": "保存",
"cancel": "取消"
},
"overview": {
"agent_context": "智能体",
"access": "访问权限",
"attention_title": "Task 需要处理",
"attention_queued_other": "运行时不可用,当前有 {{count}} 个 task 正在排队。"
},
"skill_attach": {
"trigger_aria": "附加工作区 skill",
"trigger_label": "附加"
@@ -203,6 +215,13 @@
"clear_full": "清除选择(使用提供方默认)"
},
"tabs": {
"overview": "概览",
"work": "工作",
"capabilities": "能力",
"settings": "设置",
"general": "通用",
"page_navigation_aria": "智能体页面",
"section_navigation_aria": "智能体分区",
"activity": "动态",
"tasks": "Tasks",
"instructions": "指令",
@@ -395,6 +414,15 @@
"members_note": "只有工作区的所有者和管理员才能把智能体连接到外部聊天平台。你可以在「设置 → 集成」中查看已连接的 Bot。"
},
"activity": {
"status": {
"queued": "排队中",
"dispatched": "已分发",
"waiting_local_directory": "等待目录",
"running": "进行中",
"completed": "成功",
"failed": "失败",
"cancelled": "已取消"
},
"section_now": "当前",
"section_last_30d": "近 30 天",
"section_recent": "最近工作",
@@ -410,8 +438,11 @@
"show_more": "查看更多 →",
"runs_other": "次运行",
"success_pct": "{{percent}}% 成功",
"success_label": "成功",
"avg_duration": "平均 {{value}}",
"avg_duration_label": "平均耗时",
"failed_count": "{{count}} 次失败",
"failed_label": "失败",
"source_issue": "Issue",
"source_chat": "聊天",
"source_autopilot": "自动化",