diff --git a/packages/views/agents/components/agent-detail-inspector.tsx b/packages/views/agents/components/agent-detail-inspector.tsx index f3999af909..3841f2932d 100644 --- a/packages/views/agents/components/agent-detail-inspector.tsx +++ b/packages/views/agents/components/agent-detail-inspector.tsx @@ -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) => Promise; - /** - * 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 ( - +
+ $.inspector.section_access)}> + + $.inspector.prop_visibility)} interactive={false}> + 0 + } + onChange={(next) => update(next)} + /> + + + + + $.inspector.section_details)}> + + {owner && ( + $.inspector.prop_owner)} interactive={false}> + + + {owner.name} + + + )} + $.inspector.prop_created)} interactive={false}> + + {timeAgo(agent.created_at)} + + + $.inspector.prop_updated)} interactive={false}> + + {timeAgo(agent.updated_at)} + + + + +
+ ); } @@ -262,21 +192,27 @@ export function AgentDetailInspector({ // Layout helpers // --------------------------------------------------------------------------- -function Section({ - label, +function SettingsCard({ + title, children, }: { - label: string; + title: string; children: ReactNode; }) { return ( -
-
- {label} -
-
- {children} +
+
+

{title}

+
{children}
+
+ ); +} + +function PropertyGrid({ children }: { children: ReactNode }) { + return ( +
+ {children}
); } @@ -495,7 +431,11 @@ function DescriptionEditorBody({ onClick={() => void commit()} disabled={saving || overLimit || !dirty} > - {saving ? : t(($) => $.inspector.save)} + {saving ? ( + + ) : ( + t(($) => $.inspector.save) + )} @@ -629,7 +569,7 @@ function InlineEditPopover({ disabled={saving || draft === value} > {saving ? ( - + ) : ( t(($) => $.inspector.save) )} @@ -640,35 +580,3 @@ function InlineEditPopover({ ); } - -// --------------------------------------------------------------------------- -// 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 ( - - ); - } - const av = availabilityConfig[presence.availability]; - return ( -
- - - {t(($) => $.availability[presence.availability])} - -
- ); -} diff --git a/packages/views/agents/components/agent-detail-page.tsx b/packages/views/agents/components/agent-detail-page.tsx index 47161d325d..0cce1acf3b 100644 --- a/packages/views/agents/components/agent-detail-page.tsx +++ b/packages/views/agents/components/agent-detail-page.tsx @@ -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) {
+ useModalStore + .getState() + .open("quick-create-issue", { agent_id: agent.id }) + } onArchive={() => setConfirmArchive(true)} /> @@ -283,25 +299,17 @@ export function AgentDetailPage({ agentId }: AgentDetailPageProps) {
)} -
- + setTabNavIntent("integrations")} - /> - - 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 ( - $.page.title) }]} - leaf={ - <> -

{agent.name}

- {av && presence && ( - - - {av.label} - - )} - - } - actions={ - !isArchived && canArchive ? ( +
+
+
+ + {t(($) => $.page.title)} + + + {agent.name} +
+ +
+
+ +
+
+

+ {agent.name} +

+ +
+

+ {agent.description || t(($) => $.inspector.no_description_placeholder)} +

+
+ + + + + + + +
+
+
+ +
+ {!isArchived && canAssign && ( + + )} + {!isArchived && canArchive ? ( } + aria-label={t(($) => $.detail.more_actions_aria)} > - + - + - ) : null - } - /> + ) : null} +
+
+
+
); } @@ -435,25 +492,25 @@ function BackHeader({ paths, title }: { paths: string; title: string }) { function DetailLoadingSkeleton() { return (
- - - -
-
+
+ +
- - -
- - - +
+ + +
-
- - - - +
+
+ +
+
+ + +
+
diff --git a/packages/views/agents/components/agent-overview-pane.test.tsx b/packages/views/agents/components/agent-overview-pane.test.tsx index 0a53497338..5d5d8c5b2c 100644 --- a/packages/views/agents/components/agent-overview-pane.test.tsx +++ b/packages/views/agents/components/agent-overview-pane.test.tsx @@ -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: () =>
activity-tab
, + AgentPerformanceSummary: () =>
performance-summary
, +})); +vi.mock("./agent-overview-summary", () => ({ + AgentOverviewSummary: () =>
agent-overview-summary
, })); vi.mock("./tabs/instructions-tab", () => ({ InstructionsTab: () =>
instructions-tab
, @@ -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( - - - + + + + + , ); } +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(); }); }); diff --git a/packages/views/agents/components/agent-overview-pane.tsx b/packages/views/agents/components/agent-overview-pane.tsx index 06e8c93fca..2cb4a100f0 100644 --- a/packages/views/agents/components/agent-overview-pane.tsx +++ b/packages/views/agents/components/agent-overview-pane.tsx @@ -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 = { - 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( + CAPABILITY_TABS.map((tab) => tab.id), +); +const SETTINGS_IDS = new Set(SETTINGS_TABS.map((tab) => tab.id)); +const DETAIL_VIEWS = new Set([ + "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) => Promise; - /** - * 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("activity"); + const navigation = useNavigation(); + const urlView = navigation.searchParams.get("view"); + const composioMCPAppsEnabled = useFeatureEnabled( + COMPOSIO_MCP_APPS_FLAG, + false, + ); + const [activeView, setActiveView] = useState(() => + 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(null); + const [pendingView, setPendingView] = useState(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([ + "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. -
-
- {visibleTabs.map((tab) => ( - - ))} +
+
$.tabs.page_navigation_aria)} + > +
+ {TOP_TABS.map((tab) => ( + + ))} +
-
- {effectiveTab === "activity" && } - {effectiveTab === "tasks" && ( -
+ {secondaryTabs.length > 0 && ( +
+
$.tabs.section_navigation_aria)} + > + {secondaryTabs.map((tab) => ( + + ))} +
+
+ )} + +
+ {effectiveView === "overview" && ( +
+ {needsAttention && ( +
+
+ )} + +
+ + +
+
+ )} + + {effectiveView === "work" && ( +
)} - {effectiveTab === "instructions" && ( + + {effectiveView === "instructions" && ( )} - {effectiveTab === "skills" && ( + {effectiveView === "skills" && ( )} - {effectiveTab === "env" && ( + {effectiveView === "mcp_config" && ( - onUpdate(agent.id, updates)} onDirtyChange={setActiveDirty} /> )} - {effectiveTab === "custom_args" && ( + {effectiveView === "composio_mcp" && ( + + + + )} + {effectiveView === "integrations" && ( + + + + )} + + {effectiveView === "general" && ( + + + + )} + {effectiveView === "env" && ( + + + + )} + {effectiveView === "custom_args" && ( )} - {effectiveTab === "mcp_config" && ( - - onUpdate(agent.id, updates)} - onDirtyChange={setActiveDirty} - /> - - )} - {effectiveTab === "composio_mcp" && ( - - - - )} - {effectiveTab === "integrations" && ( - - - - )} - {effectiveTab === "runtime_config" && ( + {effectiveView === "runtime_config" && ( - {pendingTab !== null && ( + {pendingView !== null && ( { - if (!v) setPendingTab(null); + onOpenChange={(open) => { + if (!open) setPendingView(null); }} > - {t(($) => $.tabs.discard_dialog_title)} + + {t(($) => $.tabs.discard_dialog_title)} + {t(($) => $.tabs.discard_dialog_description)} - {t(($) => $.tabs.discard_keep)} + + {t(($) => $.tabs.discard_keep)} + {t(($) => $.tabs.discard_confirm)} @@ -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 ( -
{children}
+
+ {children} +
); } diff --git a/packages/views/agents/components/agent-overview-summary.tsx b/packages/views/agents/components/agent-overview-summary.tsx new file mode 100644 index 0000000000..e0c4e1605f --- /dev/null +++ b/packages/views/agents/components/agent-overview-summary.tsx @@ -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 ( + + ); +} + +function SummaryRow({ + label, + children, +}: { + label: string; + children: React.ReactNode; +}) { + return ( +
+
{label}
+
{children}
+
+ ); +} diff --git a/packages/views/agents/components/tabs/activity-tab.tsx b/packages/views/agents/components/tabs/activity-tab.tsx index a82fb0656f..e1e96f5345 100644 --- a/packages/views/agents/components/tabs/activity-tab.tsx +++ b/packages/views/agents/components/tabs/activity-tab.tsx @@ -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> = { + 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 ( -
+
- + {showPerformance && ( + + )} deriveAvgDurationLast30d(agentTasks, Date.now()), + [agentTasks], + ); + const successPct = + summary.totalRuns > 0 + ? Math.round( + ((summary.totalRuns - summary.totalFailed) / summary.totalRuns) * + 100, + ) + : 100; + + return ( +
+

+ {t(($) => $.tab_body.activity.section_last_30d)} +

+ {summary.totalRuns === 0 ? ( +

+ {t(($) => $.tab_body.activity.empty_30d)} +

+ ) : ( + <> +
+ $.tab_body.activity.runs, { + count: summary.totalRuns, + })} + /> + $.tab_body.activity.success_label)} + /> + 0 ? formatDurationMs(avgDurationMs) : "—"} + label={t(($) => $.tab_body.activity.avg_duration_label)} + /> + $.tab_body.activity.failed_label)} + destructive={summary.totalFailed > 0} + /> +
+ + + )} +
+ ); +} + +function Metric({ + value, + label, + destructive = false, +}: { + value: string; + label: string; + destructive?: boolean; +}) { + return ( +
+
+ {value} +
+
{label}
+
+ ); +} + function NowSection({ tasks, issueMap, @@ -326,7 +432,13 @@ function TaskList({ agent: Agent; }) { return ( -
+
{tasks.map((task) => ( 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 (