- Create core/workspace/ with queries (members, agents, skills, list) and mutations - Create core/runtimes/ with queries - Migrate 11 consumer files from useWorkspaceStore.members/agents/skills to useQuery - Replace all WS refreshMap entries with qc.invalidateQueries - Simplify workspace store: delete members/agents/skills fields + refresh methods, hydrateWorkspace becomes synchronous (TQ auto-fetches on component mount) - Delete useRuntimeStore (no consumers left), runtimes-page uses local useState + TQ - Remove workspace→runtime cross-store dependency - Clean up dead test helper mocks Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
167 lines
5.3 KiB
TypeScript
167 lines
5.3 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from "react";
|
|
import { Lock, UserMinus } from "lucide-react";
|
|
import type { Agent, IssueAssigneeType, UpdateIssueRequest } from "@/shared/types";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import { useAuthStore } from "@/features/auth";
|
|
import { useActorName } from "@/features/workspace";
|
|
import { useWorkspaceId } from "@core/hooks";
|
|
import { memberListOptions, agentListOptions } from "@core/workspace/queries";
|
|
import { ActorAvatar } from "@/components/common/actor-avatar";
|
|
import {
|
|
PropertyPicker,
|
|
PickerItem,
|
|
PickerSection,
|
|
PickerEmpty,
|
|
} from "./property-picker";
|
|
|
|
export function canAssignAgent(agent: Agent, userId: string | undefined, memberRole: string | undefined): boolean {
|
|
if (agent.visibility !== "private") return true;
|
|
if (agent.owner_id === userId) return true;
|
|
if (memberRole === "owner" || memberRole === "admin") return true;
|
|
return false;
|
|
}
|
|
|
|
export function AssigneePicker({
|
|
assigneeType,
|
|
assigneeId,
|
|
onUpdate,
|
|
trigger: customTrigger,
|
|
triggerRender,
|
|
open: controlledOpen,
|
|
onOpenChange: controlledOnOpenChange,
|
|
align,
|
|
}: {
|
|
assigneeType: IssueAssigneeType | null;
|
|
assigneeId: string | null;
|
|
onUpdate: (updates: Partial<UpdateIssueRequest>) => void;
|
|
trigger?: React.ReactNode;
|
|
triggerRender?: React.ReactElement;
|
|
open?: boolean;
|
|
onOpenChange?: (v: boolean) => void;
|
|
align?: "start" | "center" | "end";
|
|
}) {
|
|
const [internalOpen, setInternalOpen] = useState(false);
|
|
const open = controlledOpen ?? internalOpen;
|
|
const setOpen = controlledOnOpenChange ?? setInternalOpen;
|
|
const [filter, setFilter] = useState("");
|
|
const user = useAuthStore((s) => s.user);
|
|
const wsId = useWorkspaceId();
|
|
const { data: members = [] } = useQuery(memberListOptions(wsId));
|
|
const { data: agents = [] } = useQuery(agentListOptions(wsId));
|
|
const { getActorName } = useActorName();
|
|
|
|
const currentMember = members.find((m) => m.user_id === user?.id);
|
|
const memberRole = currentMember?.role;
|
|
|
|
const query = filter.toLowerCase();
|
|
const filteredMembers = members.filter((m) =>
|
|
m.name.toLowerCase().includes(query),
|
|
);
|
|
const filteredAgents = agents.filter((a) =>
|
|
!a.archived_at && a.name.toLowerCase().includes(query),
|
|
);
|
|
|
|
const isSelected = (type: string, id: string) =>
|
|
assigneeType === type && assigneeId === id;
|
|
|
|
const triggerLabel =
|
|
assigneeType && assigneeId
|
|
? getActorName(assigneeType, assigneeId)
|
|
: "Unassigned";
|
|
|
|
return (
|
|
<PropertyPicker
|
|
open={open}
|
|
onOpenChange={(v: boolean) => {
|
|
setOpen(v);
|
|
if (!v) setFilter("");
|
|
}}
|
|
width="w-52"
|
|
align={align}
|
|
searchable
|
|
searchPlaceholder="Assign to..."
|
|
onSearchChange={setFilter}
|
|
triggerRender={triggerRender}
|
|
trigger={
|
|
customTrigger ? customTrigger : assigneeType && assigneeId ? (
|
|
<>
|
|
<ActorAvatar actorType={assigneeType} actorId={assigneeId} size={18} />
|
|
<span className="truncate">{triggerLabel}</span>
|
|
</>
|
|
) : (
|
|
<span className="text-muted-foreground">Unassigned</span>
|
|
)
|
|
}
|
|
>
|
|
{/* Unassigned option */}
|
|
<PickerItem
|
|
selected={!assigneeType && !assigneeId}
|
|
onClick={() => {
|
|
onUpdate({ assignee_type: null, assignee_id: null });
|
|
setOpen(false);
|
|
}}
|
|
>
|
|
<UserMinus className="h-3.5 w-3.5 text-muted-foreground" />
|
|
<span className="text-muted-foreground">Unassigned</span>
|
|
</PickerItem>
|
|
|
|
{/* Members */}
|
|
{filteredMembers.length > 0 && (
|
|
<PickerSection label="Members">
|
|
{filteredMembers.map((m) => (
|
|
<PickerItem
|
|
key={m.user_id}
|
|
selected={isSelected("member", m.user_id)}
|
|
onClick={() => {
|
|
onUpdate({
|
|
assignee_type: "member",
|
|
assignee_id: m.user_id,
|
|
});
|
|
setOpen(false);
|
|
}}
|
|
>
|
|
<ActorAvatar actorType="member" actorId={m.user_id} size={18} />
|
|
<span>{m.name}</span>
|
|
</PickerItem>
|
|
))}
|
|
</PickerSection>
|
|
)}
|
|
|
|
{/* Agents */}
|
|
{filteredAgents.length > 0 && (
|
|
<PickerSection label="Agents">
|
|
{filteredAgents.map((a) => {
|
|
const allowed = canAssignAgent(a, user?.id, memberRole);
|
|
return (
|
|
<PickerItem
|
|
key={a.id}
|
|
selected={isSelected("agent", a.id)}
|
|
disabled={!allowed}
|
|
onClick={() => {
|
|
if (!allowed) return;
|
|
onUpdate({
|
|
assignee_type: "agent",
|
|
assignee_id: a.id,
|
|
});
|
|
setOpen(false);
|
|
}}
|
|
>
|
|
<ActorAvatar actorType="agent" actorId={a.id} size={18} />
|
|
<span className={allowed ? "" : "text-muted-foreground"}>{a.name}</span>
|
|
{a.visibility === "private" && (
|
|
<Lock className="ml-auto h-3 w-3 text-muted-foreground" />
|
|
)}
|
|
</PickerItem>
|
|
);
|
|
})}
|
|
</PickerSection>
|
|
)}
|
|
|
|
{filteredMembers.length === 0 &&
|
|
filteredAgents.length === 0 &&
|
|
filter && <PickerEmpty />}
|
|
</PropertyPicker>
|
|
);
|
|
}
|