Compare commits

...

1 Commits

Author SHA1 Message Date
Naiyuan Qing
46b91587ae feat(agents): show model + thinking level on agent hover card
The agent profile hover card (AgentProfileCard) previously omitted the
model. Add a Model meta row (raw id, matching the inspector) and, when the
agent has a non-empty thinking_level override, a thinking-level chip next to
it. The chip reuses the skills-chip visual and resolves the runtime-native
token to its friendly catalog label (e.g. "xhigh" -> "Extra high") via the
same RuntimeModel.thinking catalog the inspector's ThinkingPicker uses.

The model catalog is only fetched when there is an override to resolve AND
the runtime is online, so agents with no override (the common case, where
no chip renders) never trigger daemon model discovery from this passive
hover surface. Cold cache / offline degrades to the raw token, which is the
accepted behavior.

Adds the `profile_card.model_label` i18n key across en/zh-Hans/ja/ko and a
regression test covering label resolution, the raw-token fallback, the
no-override case, and the default-model case.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-10 13:59:10 +08:00
6 changed files with 329 additions and 5 deletions

View File

@@ -0,0 +1,231 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, cleanup } from "@testing-library/react";
import { I18nProvider } from "@multica/core/i18n/react";
import enCommon from "../../locales/en/common.json";
import enAgents from "../../locales/en/agents.json";
const TEST_RESOURCES = { en: { common: enCommon, agents: enAgents } };
vi.mock("@multica/core/hooks", () => ({
useWorkspaceId: () => "ws-1",
}));
// Preserve the real paths module (sub-components import other helpers from it)
// and only stub the one path the card actually calls.
vi.mock("@multica/core/paths", async () => {
const actual =
await vi.importActual<typeof import("@multica/core/paths")>(
"@multica/core/paths",
);
return {
...actual,
useWorkspacePaths: () => ({
agentDetail: (id: string) => `/test/agents/${id}`,
}),
};
});
vi.mock("@multica/core/workspace/avatar-url", () => ({
resolvePublicFileUrl: (url: string | null) => url,
}));
vi.mock("../../navigation", () => ({
AppLink: ({
href,
children,
...rest
}: {
href: string;
children: React.ReactNode;
[k: string]: unknown;
}) => (
<a href={href} {...rest}>
{children}
</a>
),
}));
const mockAgents = vi.hoisted(() => ({ current: [] as unknown[] }));
const mockRuntimes = vi.hoisted(() => ({ current: [] as unknown[] }));
const mockModels = vi.hoisted(() => ({ current: undefined as unknown }));
const mockPresence = vi.hoisted(() => ({ current: "loading" as unknown }));
// Distinguish the card's four queries by their key shape:
// ["workspaces", wsId, "agents"] — agent list
// ["workspaces", wsId, "members"] — member list
// ["runtimes", wsId, "list"] — runtime list
// ["runtimes", "models", rtId] — runtime model catalog (gated by enabled)
vi.mock("@tanstack/react-query", async () => {
const actual = await vi.importActual<typeof import("@tanstack/react-query")>(
"@tanstack/react-query",
);
return {
...actual,
useQuery: (opts: { queryKey: readonly unknown[]; enabled?: boolean }) => {
const key = opts.queryKey;
const root = key[0];
const marker = key[2];
if (root === "workspaces" && marker === "agents") {
return { data: mockAgents.current, isLoading: false };
}
if (root === "workspaces" && marker === "members") {
return { data: [], isLoading: false };
}
if (root === "runtimes" && key[1] === "models") {
// Mirrors the real `enabled: Boolean(runtimeId)` gate: a disabled
// catalog query (offline / no override) exposes no data.
return { data: opts.enabled ? mockModels.current : undefined, isLoading: false };
}
if (root === "runtimes" && marker === "list") {
return { data: mockRuntimes.current, isLoading: false };
}
return { data: undefined, isLoading: false };
},
};
});
vi.mock("@multica/core/agents", async () => {
const actual = await vi.importActual<typeof import("@multica/core/agents")>(
"@multica/core/agents",
);
return {
...actual,
useAgentPresenceDetail: () => mockPresence.current,
};
});
import { AgentProfileCard } from "./agent-profile-card";
function makeAgent(overrides: Record<string, unknown> = {}) {
return {
id: "agent-1",
workspace_id: "ws-1",
runtime_id: "rt-1",
name: "Walt",
description: "",
instructions: "",
avatar_url: null,
runtime_mode: "local" as const,
runtime_config: {},
custom_args: [],
visibility: "workspace" as const,
status: "idle" as const,
max_concurrent_tasks: 1,
model: "claude-opus-4-8",
thinking_level: "",
owner_id: null,
skills: [],
created_at: "2026-04-01T00:00:00Z",
updated_at: "2026-04-01T00:00:00Z",
archived_at: null,
archived_by: null,
...overrides,
};
}
function makeRuntime(overrides: Record<string, unknown> = {}) {
return {
id: "rt-1",
workspace_id: "ws-1",
daemon_id: null,
name: "Mac-Studio",
runtime_mode: "local" as const,
provider: "claude",
launch_header: "",
status: "online" as const,
device_info: "",
metadata: {},
owner_id: null,
visibility: "private" as const,
last_seen_at: new Date().toISOString(),
created_at: "2026-04-01T00:00:00Z",
updated_at: "2026-04-01T00:00:00Z",
...overrides,
};
}
// Catalog with the friendly label for the persisted token ("xhigh" → "Extra high").
const CATALOG = {
models: [
{
id: "claude-opus-4-8",
label: "Opus 4.8",
default: true,
thinking: {
supported_levels: [
{ value: "high", label: "High" },
{ value: "xhigh", label: "Extra high" },
],
},
},
],
supported: true,
};
function renderCard() {
return render(
<I18nProvider locale="en" resources={TEST_RESOURCES}>
<AgentProfileCard agentId="agent-1" />
</I18nProvider>,
);
}
beforeEach(() => {
vi.clearAllMocks();
cleanup();
mockAgents.current = [makeAgent()];
mockRuntimes.current = [makeRuntime()];
mockModels.current = CATALOG;
mockPresence.current = {
availability: "online",
workload: "idle",
runningCount: 0,
queuedCount: 0,
capacity: 1,
};
});
describe("AgentProfileCard model + thinking level", () => {
it("shows the model id and no thinking chip when no override is set", () => {
mockAgents.current = [makeAgent({ model: "claude-opus-4-8", thinking_level: "" })];
renderCard();
expect(screen.getByText(enAgents.profile_card.model_label)).toBeInTheDocument();
expect(screen.getByText("claude-opus-4-8")).toBeInTheDocument();
// An empty override renders no chip — neither a label nor a raw token.
expect(screen.queryByText("Extra high")).toBeNull();
expect(screen.queryByText("xhigh")).toBeNull();
});
it("resolves the thinking token to its catalog label when the runtime is online", () => {
mockAgents.current = [makeAgent({ thinking_level: "xhigh" })];
mockRuntimes.current = [makeRuntime({ status: "online" })];
mockModels.current = CATALOG;
renderCard();
expect(screen.getByText("Extra high")).toBeInTheDocument();
});
it("falls back to the raw token when the catalog is unavailable (runtime offline)", () => {
mockAgents.current = [makeAgent({ thinking_level: "xhigh" })];
mockRuntimes.current = [makeRuntime({ status: "offline" })];
renderCard();
// Catalog query is disabled while offline → the raw token shows verbatim.
expect(screen.getByText("xhigh")).toBeInTheDocument();
expect(screen.queryByText("Extra high")).toBeNull();
});
it("shows the default label when the agent has no explicit model", () => {
mockAgents.current = [makeAgent({ model: "", thinking_level: "" })];
renderCard();
expect(screen.getByText(enAgents.pickers.model_default)).toBeInTheDocument();
});
});

View File

@@ -1,11 +1,12 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import type { Agent, AgentRuntime } from "@multica/core/types";
import type { Agent, AgentRuntime, RuntimeModel } from "@multica/core/types";
import { useAgentPresenceDetail } from "@multica/core/agents";
import { useWorkspaceId } from "@multica/core/hooks";
import {
deriveRuntimeHealth,
runtimeModelsOptions,
type RuntimeHealth,
} from "@multica/core/runtimes";
import { agentListOptions, memberListOptions } from "@multica/core/workspace/queries";
@@ -33,6 +34,22 @@ export function AgentProfileCard({ agentId }: AgentProfileCardProps) {
const { data: runtimes = [] } = useQuery(runtimeListOptions(wsId));
const agent = agents.find((a) => a.id === agentId);
const runtime = agent
? runtimes.find((r) => r.id === agent.runtime_id) ?? null
: null;
// Friendly thinking-level label comes from the runtime model catalog
// (RuntimeModel.thinking.supported_levels[].label) — the same source the
// inspector's ThinkingPicker uses. Only fetch it when the agent actually has
// a thinking override AND its runtime is online: agents with no override (the
// common case, where no chip renders) never trigger daemon model discovery
// from this passive hover surface. Cold cache / offline → raw-token fallback.
const runtimeOnline = runtime?.status === "online";
const thinkingLevel = agent?.thinking_level ?? "";
const modelsQuery = useQuery(
runtimeModelsOptions(
agent && thinkingLevel && runtimeOnline ? agent.runtime_id : null,
),
);
if (agentsLoading && !agent) {
return (
@@ -55,8 +72,11 @@ export function AgentProfileCard({ agentId }: AgentProfileCardProps) {
const owner = agent.owner_id
? members.find((m) => m.user_id === agent.owner_id) ?? null
: null;
const runtime = runtimes.find((r) => r.id === agent.runtime_id) ?? null;
const isArchived = !!agent.archived_at;
const modelValue = agent.model ?? "";
const thinkingChipLabel = thinkingLevel
? resolveThinkingLabel(modelsQuery.data?.models ?? [], modelValue, thinkingLevel)
: null;
const initials = agent.name
.split(" ")
.map((w) => w[0])
@@ -113,11 +133,19 @@ export function AgentProfileCard({ agentId }: AgentProfileCardProps) {
</p>
)}
{/* Meta rows — minimal set: runtime (where it lives), skills (what
it knows), owner (who manages it). Model is intentionally
omitted — power-user detail lives on the detail page. */}
{/* Meta rows — runtime (where it lives), model + thinking level (what it
runs), skills (what it knows), owner (who manages it). The thinking
chip renders only when the agent has a non-empty override. */}
<div className="flex flex-col gap-1.5 text-xs">
<RuntimeRow agent={agent} runtime={runtime} />
<ModelRow
model={modelValue || t(($) => $.pickers.model_default)}
isDefault={!modelValue}
thinkingLabel={thinkingChipLabel}
thinkingTitle={t(($) => $.pickers.thinking_tooltip, {
value: thinkingChipLabel ?? "",
})}
/>
{agent.skills.length > 0 && (
<SkillsRow skills={agent.skills.map((s) => s.name)} />
)}
@@ -189,6 +217,67 @@ function RuntimeRow({
);
}
// Resolve a persisted thinking_level token to its friendly catalog label
// (e.g. "xhigh" → "Extra high"), mirroring ThinkingPicker's lookup. Falls back
// to the raw token when the catalog is unavailable (runtime offline, cold
// cache, or the model/level dropped from the catalog) — an accepted
// degradation for this glance surface.
function resolveThinkingLabel(
models: RuntimeModel[],
model: string,
value: string,
): string {
const entry = model
? models.find((m) => m.id === model)
: models.find((m) => m.default) ?? models[0];
return (
entry?.thinking?.supported_levels.find((l) => l.value === value)?.label ??
value
);
}
// Model row + optional thinking-level chip. Model is shown as the raw id
// (matching the inspector, which never resolves it to a friendly label); the
// thinking chip reuses the skills-chip visual and is rendered only when the
// agent has a non-empty thinking override. The model value gets a `title` so
// long ids that truncate stay readable on hover.
function ModelRow({
model,
isDefault,
thinkingLabel,
thinkingTitle,
}: {
model: string;
isDefault: boolean;
thinkingLabel: string | null;
thinkingTitle: string;
}) {
const { t } = useT("agents");
return (
<div className="flex items-center gap-1.5">
<span className="w-12 shrink-0 text-muted-foreground">
{t(($) => $.profile_card.model_label)}
</span>
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
<span
className={`min-w-0 truncate ${isDefault ? "" : "font-mono text-[11px]"}`}
title={model}
>
{model}
</span>
{thinkingLabel && (
<span
className="shrink-0 rounded-md bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground"
title={thinkingTitle}
>
{thinkingLabel}
</span>
)}
</div>
</div>
);
}
function MetaRow({
label,
value,

View File

@@ -406,6 +406,7 @@
"unavailable": "Agent unavailable",
"detail_link": "Detail →",
"runtime_label": "Runtime",
"model_label": "Model",
"skills_label": "Skills",
"owner_label": "Owner",
"unknown_runtime": "Unknown runtime"

View File

@@ -388,6 +388,7 @@
"unavailable": "エージェントを利用できません",
"detail_link": "詳細 →",
"runtime_label": "ランタイム",
"model_label": "モデル",
"skills_label": "スキル",
"owner_label": "オーナー",
"unknown_runtime": "不明なランタイム"

View File

@@ -406,6 +406,7 @@
"unavailable": "에이전트를 사용할 수 없습니다",
"detail_link": "자세히 →",
"runtime_label": "런타임",
"model_label": "모델",
"skills_label": "스킬",
"owner_label": "소유자",
"unknown_runtime": "알 수 없는 런타임"

View File

@@ -396,6 +396,7 @@
"unavailable": "智能体不可用",
"detail_link": "详情 →",
"runtime_label": "运行时",
"model_label": "模型",
"skills_label": "Skills",
"owner_label": "所有者",
"unknown_runtime": "未知运行时"