feat(runtime): use avatar for owner display and add owner filter

- Show owner avatar + name in runtime list items (replaces text-only)
- Show owner avatar + name in runtime detail info grid
- Add per-owner filter pills in "All" mode for quick filtering

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
yushen
2026-04-09 13:53:24 +08:00
parent 711ab886e2
commit c12103ef1c
3 changed files with 89 additions and 21 deletions

View File

@@ -20,6 +20,7 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from "@multica/ui/components/ui/alert-dialog";
import { ActorAvatar } from "../../common/actor-avatar";
import { formatLastSeen } from "../utils";
import { RuntimeModeIcon, StatusBadge, InfoField } from "./shared";
import { PingSection } from "./ping-section";
@@ -50,9 +51,8 @@ export function RuntimeDetail({ runtime }: { runtime: AgentRuntime }) {
// Resolve owner info
const ownerMember = runtime.owner_id
? members.find((m) => m.user_id === runtime.owner_id)
? members.find((m) => m.user_id === runtime.owner_id) ?? null
: null;
const ownerName = ownerMember?.name ?? null;
// Permission check for delete
const currentMember = user
@@ -118,7 +118,19 @@ export function RuntimeDetail({ runtime }: { runtime: AgentRuntime }) {
label="Last Seen"
value={formatLastSeen(runtime.last_seen_at)}
/>
{ownerName && <InfoField label="Owner" value={ownerName} />}
{ownerMember && (
<div>
<div className="text-xs text-muted-foreground mb-1">Owner</div>
<div className="flex items-center gap-2">
<ActorAvatar
actorType="member"
actorId={ownerMember.user_id}
size={20}
/>
<span className="text-sm">{ownerMember.name}</span>
</div>
</div>
)}
{runtime.device_info && (
<InfoField label="Device" value={runtime.device_info} />
)}

View File

@@ -1,8 +1,9 @@
import { Server } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import type { AgentRuntime } from "@multica/core/types";
import type { AgentRuntime, MemberWithUser } from "@multica/core/types";
import { useWorkspaceId } from "@multica/core/hooks";
import { memberListOptions } from "@multica/core/workspace/queries";
import { ActorAvatar } from "../../common/actor-avatar";
import { RuntimeModeIcon } from "./shared";
type RuntimeFilter = "mine" | "all";
@@ -10,12 +11,12 @@ type RuntimeFilter = "mine" | "all";
function RuntimeListItem({
runtime,
isSelected,
ownerName,
ownerMember,
onClick,
}: {
runtime: AgentRuntime;
isSelected: boolean;
ownerName: string | null;
ownerMember: MemberWithUser | null;
onClick: () => void;
}) {
return (
@@ -34,9 +35,19 @@ function RuntimeListItem({
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium">{runtime.name}</div>
<div className="mt-0.5 truncate text-xs text-muted-foreground">
{runtime.provider} &middot; {runtime.runtime_mode}
{ownerName && <> &middot; {ownerName}</>}
<div className="mt-0.5 flex items-center gap-1 text-xs text-muted-foreground">
{ownerMember && (
<>
<ActorAvatar
actorType="member"
actorId={ownerMember.user_id}
size={14}
/>
<span className="truncate">{ownerMember.name}</span>
<span>&middot;</span>
</>
)}
<span className="truncate">{runtime.provider}</span>
</div>
</div>
<div
@@ -54,35 +65,51 @@ export function RuntimeList({
onSelect,
filter,
onFilterChange,
ownerFilter,
onOwnerFilterChange,
}: {
runtimes: AgentRuntime[];
selectedId: string;
onSelect: (id: string) => void;
filter: RuntimeFilter;
onFilterChange: (filter: RuntimeFilter) => void;
ownerFilter: string | null;
onOwnerFilterChange: (ownerId: string | null) => void;
}) {
const wsId = useWorkspaceId();
const { data: members = [] } = useQuery(memberListOptions(wsId));
const getOwnerName = (ownerId: string | null) => {
const getOwnerMember = (ownerId: string | null) => {
if (!ownerId) return null;
return members.find((m) => m.user_id === ownerId)?.name ?? null;
return members.find((m) => m.user_id === ownerId) ?? null;
};
// Get unique owners from runtimes for filter dropdown
const uniqueOwners = filter === "all"
? Array.from(new Set(runtimes.map((r) => r.owner_id).filter(Boolean) as string[]))
.map((id) => members.find((m) => m.user_id === id))
.filter(Boolean) as MemberWithUser[]
: [];
// Apply client-side owner filter when in "all" mode
const filteredRuntimes = filter === "all" && ownerFilter
? runtimes.filter((r) => r.owner_id === ownerFilter)
: runtimes;
return (
<div className="overflow-y-auto h-full border-r">
<div className="flex h-12 items-center justify-between border-b px-4">
<h1 className="text-sm font-semibold">Runtimes</h1>
<span className="text-xs text-muted-foreground">
{runtimes.filter((r) => r.status === "online").length}/
{runtimes.length} online
{filteredRuntimes.filter((r) => r.status === "online").length}/
{filteredRuntimes.length} online
</span>
</div>
{/* Filter toggle */}
<div className="flex border-b px-4 py-2 gap-1">
<div className="flex items-center gap-1 border-b px-4 py-2">
<button
onClick={() => onFilterChange("mine")}
onClick={() => { onFilterChange("mine"); onOwnerFilterChange(null); }}
className={`rounded-md px-2.5 py-1 text-xs font-medium transition-colors ${
filter === "mine"
? "bg-accent text-accent-foreground"
@@ -92,22 +119,48 @@ export function RuntimeList({
Mine
</button>
<button
onClick={() => onFilterChange("all")}
onClick={() => { onFilterChange("all"); onOwnerFilterChange(null); }}
className={`rounded-md px-2.5 py-1 text-xs font-medium transition-colors ${
filter === "all"
filter === "all" && !ownerFilter
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
>
All
</button>
{/* Owner filter pills (shown in "all" mode when there are multiple owners) */}
{filter === "all" && uniqueOwners.length > 1 && (
<>
<div className="mx-1 h-4 w-px bg-border" />
{uniqueOwners.map((m) => (
<button
key={m.user_id}
onClick={() => onOwnerFilterChange(ownerFilter === m.user_id ? null : m.user_id)}
className={`flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium transition-colors ${
ownerFilter === m.user_id
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
title={m.name}
>
<ActorAvatar
actorType="member"
actorId={m.user_id}
size={14}
/>
<span className="max-w-16 truncate">{m.name}</span>
</button>
))}
</>
)}
</div>
{runtimes.length === 0 ? (
{filteredRuntimes.length === 0 ? (
<div className="flex flex-col items-center justify-center px-4 py-12">
<Server className="h-8 w-8 text-muted-foreground/40" />
<p className="mt-3 text-sm text-muted-foreground">
{filter === "mine" ? "No runtimes owned by you" : "No runtimes registered"}
{filter === "mine" ? "No runtimes owned by you" : ownerFilter ? "No runtimes for this owner" : "No runtimes registered"}
</p>
<p className="mt-1 text-xs text-muted-foreground text-center">
Run{" "}
@@ -119,12 +172,12 @@ export function RuntimeList({
</div>
) : (
<div className="divide-y">
{runtimes.map((runtime) => (
{filteredRuntimes.map((runtime) => (
<RuntimeListItem
key={runtime.id}
runtime={runtime}
isSelected={runtime.id === selectedId}
ownerName={filter === "all" ? getOwnerName(runtime.owner_id) : null}
ownerMember={getOwnerMember(runtime.owner_id)}
onClick={() => onSelect(runtime.id)}
/>
))}

View File

@@ -24,6 +24,7 @@ export default function RuntimesPage() {
const wsId = useWorkspaceId();
const qc = useQueryClient();
const [filter, setFilter] = useState<RuntimeFilter>("mine");
const [ownerFilter, setOwnerFilter] = useState<string | null>(null);
const [selectedId, setSelectedId] = useState("");
const ownerParam = filter === "mine" ? "me" as const : undefined;
@@ -102,6 +103,8 @@ export default function RuntimesPage() {
onSelect={setSelectedId}
filter={filter}
onFilterChange={setFilter}
ownerFilter={ownerFilter}
onOwnerFilterChange={setOwnerFilter}
/>
</ResizablePanel>