mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-26 20:45:37 +02:00
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:
@@ -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} />
|
||||
)}
|
||||
|
||||
@@ -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} · {runtime.runtime_mode}
|
||||
{ownerName && <> · {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>·</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)}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user