mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-03 11:10:23 +02:00
refactor(agents): redesign agent detail workbench
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
130
packages/views/agents/components/agent-overview-summary.tsx
Normal file
130
packages/views/agents/components/agent-overview-summary.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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) });
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "オートパイロット",
|
||||
|
||||
@@ -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": "오토파일럿",
|
||||
|
||||
@@ -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": "自动化",
|
||||
|
||||
Reference in New Issue
Block a user