Compare commits

...

3 Commits

Author SHA1 Message Date
Devv
b31a76941f fix(agents): address PR review — use unfiltered runtimes for lookup, simplify IIFE
- Look up selectedRuntime from full `runtimes` array instead of
  `filteredRuntimes` to avoid null flash when switching filters
- Replace IIFE with inline optional chaining for owner name display
- Fix indentation on the trigger subtitle div

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 11:49:42 +08:00
Devv
2954a248c4 feat(agents): show runtime owner and Mine/All filter in agent Settings tab
Apply the same owner display and Mine/All filter pattern to the Settings
tab's runtime selector, matching the Create Agent dialog. Uses ProviderLogo
and ActorAvatar for consistent runtime item rendering across both selectors.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 11:47:54 +08:00
Devv
2d9204f0f8 feat(agents): show runtime owner and add Mine/All filter in Create Agent dialog
Display the runtime owner (with avatar) in the runtime selector dropdown,
matching the pattern used in the Runtime list page. Add a Mine/All toggle
to filter runtimes by ownership, defaulting to "Mine" so the current user's
runtimes appear first.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 11:44:04 +08:00
4 changed files with 225 additions and 80 deletions

View File

@@ -13,7 +13,7 @@ import {
Settings,
KeyRound,
} from "lucide-react";
import type { Agent, RuntimeDevice } from "@multica/core/types";
import type { Agent, RuntimeDevice, MemberWithUser } from "@multica/core/types";
import {
Dialog,
DialogContent,
@@ -54,12 +54,16 @@ const detailTabs: { id: DetailTab; label: string; icon: typeof FileText }[] = [
export function AgentDetail({
agent,
runtimes,
members,
currentUserId,
onUpdate,
onArchive,
onRestore,
}: {
agent: Agent;
runtimes: RuntimeDevice[];
members: MemberWithUser[];
currentUserId: string | null;
onUpdate: (id: string, data: Partial<Agent>) => Promise<void>;
onArchive: (id: string) => Promise<void>;
onRestore: (id: string) => Promise<void>;
@@ -171,6 +175,8 @@ export function AgentDetail({
<SettingsTab
agent={agent}
runtimes={runtimes}
members={members}
currentUserId={currentUserId}
onSave={(updates) => onUpdate(agent.id, updates)}
/>
)}

View File

@@ -17,13 +17,14 @@ import { useAuthStore } from "@multica/core/auth";
import { runtimeListOptions } from "@multica/core/runtimes/queries";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useWorkspaceId } from "@multica/core/hooks";
import { agentListOptions, workspaceKeys } from "@multica/core/workspace/queries";
import { agentListOptions, memberListOptions, workspaceKeys } from "@multica/core/workspace/queries";
import { CreateAgentDialog } from "./create-agent-dialog";
import { AgentListItem } from "./agent-list-item";
import { AgentDetail } from "./agent-detail";
export function AgentsPage() {
const isLoading = useAuthStore((s) => s.isLoading);
const currentUser = useAuthStore((s) => s.user);
const qc = useQueryClient();
const wsId = useWorkspaceId();
const { data: agents = [] } = useQuery(agentListOptions(wsId));
@@ -31,6 +32,7 @@ export function AgentsPage() {
const [showArchived, setShowArchived] = useState(false);
const [showCreate, setShowCreate] = useState(false);
const { data: runtimes = [], isLoading: runtimesLoading } = useQuery(runtimeListOptions(wsId));
const { data: members = [] } = useQuery(memberListOptions(wsId));
const { defaultLayout, onLayoutChanged } = useDefaultLayout({
id: "multica_agents_layout",
});
@@ -201,6 +203,8 @@ export function AgentsPage() {
key={selected.id}
agent={selected}
runtimes={runtimes}
members={members}
currentUserId={currentUser?.id ?? null}
onUpdate={handleUpdate}
onArchive={handleArchive}
onRestore={handleRestore}
@@ -225,6 +229,8 @@ export function AgentsPage() {
<CreateAgentDialog
runtimes={runtimes}
runtimesLoading={runtimesLoading}
members={members}
currentUserId={currentUser?.id ?? null}
onClose={() => setShowCreate(false)}
onCreate={handleCreate}
/>

View File

@@ -1,11 +1,13 @@
"use client";
import { useState, useEffect } from "react";
import { useState, useEffect, useMemo } from "react";
import { Cloud, ChevronDown, Globe, Lock, Loader2 } from "lucide-react";
import { ProviderLogo } from "../../runtimes/components/provider-logo";
import { ActorAvatar } from "../../common/actor-avatar";
import type {
AgentVisibility,
RuntimeDevice,
MemberWithUser,
CreateAgentRequest,
} from "@multica/core/types";
import {
@@ -26,29 +28,55 @@ import { Input } from "@multica/ui/components/ui/input";
import { Label } from "@multica/ui/components/ui/label";
import { toast } from "sonner";
type RuntimeFilter = "mine" | "all";
export function CreateAgentDialog({
runtimes,
runtimesLoading,
members,
currentUserId,
onClose,
onCreate,
}: {
runtimes: RuntimeDevice[];
runtimesLoading?: boolean;
members: MemberWithUser[];
currentUserId: string | null;
onClose: () => void;
onCreate: (data: CreateAgentRequest) => Promise<void>;
}) {
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [selectedRuntimeId, setSelectedRuntimeId] = useState(runtimes[0]?.id ?? "");
const [visibility, setVisibility] = useState<AgentVisibility>("private");
const [creating, setCreating] = useState(false);
const [runtimeOpen, setRuntimeOpen] = useState(false);
const [runtimeFilter, setRuntimeFilter] = useState<RuntimeFilter>("mine");
const getOwnerMember = (ownerId: string | null) => {
if (!ownerId) return null;
return members.find((m) => m.user_id === ownerId) ?? null;
};
const hasOtherRuntimes = runtimes.some((r) => r.owner_id !== currentUserId);
const filteredRuntimes = useMemo(() => {
const filtered = runtimeFilter === "mine" && currentUserId
? runtimes.filter((r) => r.owner_id === currentUserId)
: runtimes;
return [...filtered].sort((a, b) => {
if (a.owner_id === currentUserId && b.owner_id !== currentUserId) return -1;
if (a.owner_id !== currentUserId && b.owner_id === currentUserId) return 1;
return 0;
});
}, [runtimes, runtimeFilter, currentUserId]);
const [selectedRuntimeId, setSelectedRuntimeId] = useState(filteredRuntimes[0]?.id ?? "");
useEffect(() => {
if (!selectedRuntimeId && runtimes[0]) {
setSelectedRuntimeId(runtimes[0].id);
if (!selectedRuntimeId && filteredRuntimes[0]) {
setSelectedRuntimeId(filteredRuntimes[0].id);
}
}, [runtimes, selectedRuntimeId]);
}, [filteredRuntimes, selectedRuntimeId]);
const selectedRuntime = runtimes.find((d) => d.id === selectedRuntimeId) ?? null;
@@ -141,7 +169,35 @@ export function CreateAgentDialog({
</div>
<div className="min-w-0">
<Label className="text-xs text-muted-foreground">Runtime</Label>
<div className="flex items-center justify-between">
<Label className="text-xs text-muted-foreground">Runtime</Label>
{hasOtherRuntimes && (
<div className="flex items-center gap-0.5 rounded-md bg-muted p-0.5">
<button
type="button"
onClick={() => { setRuntimeFilter("mine"); setSelectedRuntimeId(""); }}
className={`rounded px-2 py-0.5 text-xs font-medium transition-colors ${
runtimeFilter === "mine"
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
}`}
>
Mine
</button>
<button
type="button"
onClick={() => { setRuntimeFilter("all"); setSelectedRuntimeId(""); }}
className={`rounded px-2 py-0.5 text-xs font-medium transition-colors ${
runtimeFilter === "all"
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
}`}
>
All
</button>
</div>
)}
</div>
<Popover open={runtimeOpen} onOpenChange={setRuntimeOpen}>
<PopoverTrigger
disabled={runtimes.length === 0 && !runtimesLoading}
@@ -166,42 +222,56 @@ export function CreateAgentDialog({
)}
</div>
<div className="truncate text-xs text-muted-foreground">
{selectedRuntime?.device_info ?? "Register a runtime before creating an agent"}
{selectedRuntime
? (getOwnerMember(selectedRuntime.owner_id)?.name ?? selectedRuntime.device_info)
: "Register a runtime before creating an agent"}
</div>
</div>
<ChevronDown className={`h-4 w-4 shrink-0 text-muted-foreground transition-transform ${runtimeOpen ? "rotate-180" : ""}`} />
</PopoverTrigger>
<PopoverContent align="start" className="w-[var(--anchor-width)] p-1 max-h-60 overflow-y-auto">
{runtimes.map((device) => (
<button
key={device.id}
onClick={() => {
setSelectedRuntimeId(device.id);
setRuntimeOpen(false);
}}
className={`flex w-full items-center gap-3 rounded-md px-3 py-2.5 text-left text-sm transition-colors ${
device.id === selectedRuntimeId ? "bg-accent" : "hover:bg-accent/50"
}`}
>
<ProviderLogo provider={device.provider} className="h-4 w-4 shrink-0" />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate font-medium">{device.name}</span>
{device.runtime_mode === "cloud" && (
<span className="shrink-0 rounded bg-info/10 px-1.5 py-0.5 text-xs font-medium text-info">
Cloud
</span>
)}
</div>
<div className="truncate text-xs text-muted-foreground">{device.device_info}</div>
</div>
<span
className={`h-2 w-2 shrink-0 rounded-full ${
device.status === "online" ? "bg-success" : "bg-muted-foreground/40"
{filteredRuntimes.map((device) => {
const ownerMember = getOwnerMember(device.owner_id);
return (
<button
key={device.id}
onClick={() => {
setSelectedRuntimeId(device.id);
setRuntimeOpen(false);
}}
className={`flex w-full items-center gap-3 rounded-md px-3 py-2.5 text-left text-sm transition-colors ${
device.id === selectedRuntimeId ? "bg-accent" : "hover:bg-accent/50"
}`}
/>
</button>
))}
>
<ProviderLogo provider={device.provider} className="h-4 w-4 shrink-0" />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate font-medium">{device.name}</span>
{device.runtime_mode === "cloud" && (
<span className="shrink-0 rounded bg-info/10 px-1.5 py-0.5 text-xs font-medium text-info">
Cloud
</span>
)}
</div>
<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 className="truncate">{device.device_info}</span>
)}
</div>
</div>
<span
className={`h-2 w-2 shrink-0 rounded-full ${
device.status === "online" ? "bg-success" : "bg-muted-foreground/40"
}`}
/>
</button>
);
})}
</PopoverContent>
</Popover>
</div>

View File

@@ -1,9 +1,7 @@
"use client";
import { useState, useRef } from "react";
import { useState, useRef, useMemo } from "react";
import {
Cloud,
Monitor,
Loader2,
Save,
Globe,
@@ -11,7 +9,7 @@ import {
Camera,
ChevronDown,
} from "lucide-react";
import type { Agent, AgentVisibility, RuntimeDevice } from "@multica/core/types";
import type { Agent, AgentVisibility, RuntimeDevice, MemberWithUser } from "@multica/core/types";
import {
Popover,
PopoverTrigger,
@@ -24,14 +22,21 @@ import { toast } from "sonner";
import { api } from "@multica/core/api";
import { useFileUpload } from "@multica/core/hooks/use-file-upload";
import { ActorAvatar } from "../../../common/actor-avatar";
import { ProviderLogo } from "../../../runtimes/components/provider-logo";
type RuntimeFilter = "mine" | "all";
export function SettingsTab({
agent,
runtimes,
members,
currentUserId,
onSave,
}: {
agent: Agent;
runtimes: RuntimeDevice[];
members: MemberWithUser[];
currentUserId: string | null;
onSave: (updates: Partial<Agent>) => Promise<void>;
}) {
const [name, setName] = useState(agent.name);
@@ -40,11 +45,31 @@ export function SettingsTab({
const [maxTasks, setMaxTasks] = useState(agent.max_concurrent_tasks);
const [selectedRuntimeId, setSelectedRuntimeId] = useState(agent.runtime_id);
const [runtimeOpen, setRuntimeOpen] = useState(false);
const [runtimeFilter, setRuntimeFilter] = useState<RuntimeFilter>("mine");
const [saving, setSaving] = useState(false);
const { upload, uploading } = useFileUpload(api);
const fileInputRef = useRef<HTMLInputElement>(null);
const getOwnerMember = (ownerId: string | null) => {
if (!ownerId) return null;
return members.find((m) => m.user_id === ownerId) ?? null;
};
const hasOtherRuntimes = runtimes.some((r) => r.owner_id !== currentUserId);
const filteredRuntimes = useMemo(() => {
const filtered = runtimeFilter === "mine" && currentUserId
? runtimes.filter((r) => r.owner_id === currentUserId)
: runtimes;
return [...filtered].sort((a, b) => {
if (a.owner_id === currentUserId && b.owner_id !== currentUserId) return -1;
if (a.owner_id !== currentUserId && b.owner_id === currentUserId) return 1;
return 0;
});
}, [runtimes, runtimeFilter, currentUserId]);
const selectedRuntime = runtimes.find((d) => d.id === selectedRuntimeId) ?? null;
const selectedOwnerMember = selectedRuntime ? getOwnerMember(selectedRuntime.owner_id) : null;
const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
@@ -191,16 +216,44 @@ export function SettingsTab({
</div>
<div>
<Label className="text-xs text-muted-foreground">Runtime</Label>
<div className="flex items-center justify-between">
<Label className="text-xs text-muted-foreground">Runtime</Label>
{hasOtherRuntimes && (
<div className="flex items-center gap-0.5 rounded-md bg-muted p-0.5">
<button
type="button"
onClick={() => setRuntimeFilter("mine")}
className={`rounded px-2 py-0.5 text-xs font-medium transition-colors ${
runtimeFilter === "mine"
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
}`}
>
Mine
</button>
<button
type="button"
onClick={() => setRuntimeFilter("all")}
className={`rounded px-2 py-0.5 text-xs font-medium transition-colors ${
runtimeFilter === "all"
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
}`}
>
All
</button>
</div>
)}
</div>
<Popover open={runtimeOpen} onOpenChange={setRuntimeOpen}>
<PopoverTrigger
disabled={runtimes.length === 0}
className="flex w-full items-center gap-3 rounded-lg border border-border bg-background px-3 py-2.5 mt-1.5 text-left text-sm transition-colors hover:bg-muted disabled:pointer-events-none disabled:opacity-50"
>
{selectedRuntime?.runtime_mode === "cloud" ? (
<Cloud className="h-4 w-4 shrink-0 text-muted-foreground" />
{selectedRuntime ? (
<ProviderLogo provider={selectedRuntime.provider} className="h-4 w-4 shrink-0" />
) : (
<Monitor className="h-4 w-4 shrink-0 text-muted-foreground" />
<ProviderLogo provider="" className="h-4 w-4 shrink-0" />
)}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
@@ -214,46 +267,56 @@ export function SettingsTab({
)}
</div>
<div className="truncate text-xs text-muted-foreground">
{selectedRuntime?.device_info ?? "Select a runtime"}
{selectedRuntime ? (
selectedOwnerMember ? selectedOwnerMember.name : selectedRuntime.device_info
) : "Select a runtime"}
</div>
</div>
<ChevronDown className={`h-4 w-4 shrink-0 text-muted-foreground transition-transform ${runtimeOpen ? "rotate-180" : ""}`} />
</PopoverTrigger>
<PopoverContent align="start" className="w-[var(--anchor-width)] p-1 max-h-60 overflow-y-auto">
{runtimes.map((device) => (
<button
key={device.id}
onClick={() => {
setSelectedRuntimeId(device.id);
setRuntimeOpen(false);
}}
className={`flex w-full items-center gap-3 rounded-md px-3 py-2.5 text-left text-sm transition-colors ${
device.id === selectedRuntimeId ? "bg-accent" : "hover:bg-accent/50"
}`}
>
{device.runtime_mode === "cloud" ? (
<Cloud className="h-4 w-4 shrink-0 text-muted-foreground" />
) : (
<Monitor className="h-4 w-4 shrink-0 text-muted-foreground" />
)}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate font-medium">{device.name}</span>
{device.runtime_mode === "cloud" && (
<span className="shrink-0 rounded bg-info/10 px-1.5 py-0.5 text-xs font-medium text-info">
Cloud
</span>
)}
</div>
<div className="truncate text-xs text-muted-foreground">{device.device_info}</div>
</div>
<span
className={`h-2 w-2 shrink-0 rounded-full ${
device.status === "online" ? "bg-success" : "bg-muted-foreground/40"
{filteredRuntimes.map((device) => {
const ownerMember = getOwnerMember(device.owner_id);
return (
<button
key={device.id}
onClick={() => {
setSelectedRuntimeId(device.id);
setRuntimeOpen(false);
}}
className={`flex w-full items-center gap-3 rounded-md px-3 py-2.5 text-left text-sm transition-colors ${
device.id === selectedRuntimeId ? "bg-accent" : "hover:bg-accent/50"
}`}
/>
</button>
))}
>
<ProviderLogo provider={device.provider} className="h-4 w-4 shrink-0" />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate font-medium">{device.name}</span>
{device.runtime_mode === "cloud" && (
<span className="shrink-0 rounded bg-info/10 px-1.5 py-0.5 text-xs font-medium text-info">
Cloud
</span>
)}
</div>
<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 className="truncate">{device.device_info}</span>
)}
</div>
</div>
<span
className={`h-2 w-2 shrink-0 rounded-full ${
device.status === "online" ? "bg-success" : "bg-muted-foreground/40"
}`}
/>
</button>
);
})}
</PopoverContent>
</Popover>
</div>