Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
181 changes: 158 additions & 23 deletions desktop/src/apps/ProjectsApp/ProjectMembers.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { render, screen, act } from "@testing-library/react";
import { render, screen, act, fireEvent, within } from "@testing-library/react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { ProjectMembers } from "./ProjectMembers";
import type { Project } from "@/lib/projects";
Expand All @@ -24,27 +24,22 @@ async function flush() {
});
}

const project = { id: "prj-test", name: "taOS", slug: "taos" } as unknown as Project;
const baseProject = { id: "prj-test", name: "taOS", slug: "taos" } as unknown as Project;

// A consent-flow external agent: the member row keys on the canonical id, and
// the registry entry has an EMPTY handle (this is what the approve flow writes).
const externalMember = {
project_id: "prj-test",
member_id: "grok-taos-20260711-000736",
member_kind: "native",
role: "member",
is_lead: 0,
can_edit_canvas: 0,
};
const agentA = { id: "agent-a", name: "Alpha", display_name: "Alpha", emoji: "🅰️" };
const agentB = { id: "agent-b", name: "Beta", display_name: "Beta", emoji: "🅱️" };

const registryEntry = {
canonical_id: "grok-taos-20260711-000736",
handle: "",
display_name: "grok-taOS",
framework: "grok",
origin: "external-selfjoin",
status: "active",
};
function memberRow(memberId: string) {
return {
project_id: "prj-test",
member_id: memberId,
member_kind: "native",
role: "member",
is_lead: 0,
can_edit_canvas: 0,
can_read_canvas: 0,
};
}

describe("ProjectMembers external-agent categorisation", () => {
beforeEach(() => {
Expand All @@ -67,10 +62,150 @@ describe("ProjectMembers external-agent categorisation", () => {
// The section heading only renders when at least one member is classified
// external, so its presence is the regression guard: before the canonical-id
// match this agent fell into the plain Members list.
expect(screen.getByText("External / Connected agents")).toBeInTheDocument();
expect(screen.getByText("grok-taOS")).toBeInTheDocument();
const externalSection = screen.getByText("External / Connected agents").closest("section")!;
expect(externalSection).toBeInTheDocument();
expect(within(externalSection!).getByText("grok-taOS")).toBeInTheDocument();
// The framework badge resolves via the canonical-id keyed lookup, and "grok"
// (not only "grok-build") maps to the friendly Grok label.
expect(screen.getByText("Grok")).toBeInTheDocument();
expect(within(externalSection!).getByText("Grok")).toBeInTheDocument();
});
});

describe("ProjectMembers canvas capability checkboxes (slice 6)", () => {
beforeEach(() => {
vi.restoreAllMocks();
});

it("renders both a read and an edit canvas checkbox for an agent member", async () => {
vi.stubGlobal(
"fetch",
mockFetch({
"/api/projects/prj-test/members": {
ok: true,
body: { items: [memberRow("agent-a")] },
},
"/api/agents": { ok: true, body: [agentA] },
"/api/agents/registry": { ok: true, body: [] },
}),
);

render(<ProjectMembers project={baseProject} onChanged={vi.fn()} />);
await flush();

expect(screen.getByText("Can read canvas")).toBeInTheDocument();
expect(screen.getByText("Can edit canvas")).toBeInTheDocument();
expect(
screen.getByLabelText("Can read canvas for Alpha"),
).toBeInTheDocument();
expect(
screen.getByLabelText("Can edit canvas for Alpha"),
).toBeInTheDocument();
});
});

describe("ProjectMembers exclusive Lead selector (D7)", () => {
beforeEach(() => {
vi.restoreAllMocks();
});

it("reflects the current lead and promotes a new one exclusively via setLead", async () => {
const fetchMock = mockFetch({
"/api/projects/prj-test/members": {
ok: true,
body: { items: [memberRow("agent-a"), memberRow("agent-b")] },
},
"/api/agents": { ok: true, body: [agentA, agentB] },
"/api/agents/registry": { ok: true, body: [] },
"/api/projects/prj-test/lead": { ok: true, body: { ok: true, lead_member_id: "agent-b" } },
});
vi.stubGlobal("fetch", fetchMock);

const project = {
...baseProject,
lead_member_id: "agent-a",
} as unknown as Project;

render(<ProjectMembers project={project} onChanged={vi.fn()} />);
await flush();

const select = screen.getByLabelText("Project lead") as HTMLSelectElement;
// The project's exclusive lead is pre-selected.
expect(select.value).toBe("agent-a");

// Promote Beta. Because the lead is a single project pointer, only one
// option can ever be selected, so setting a new lead is inherently
// exclusive (the server unsets the previous one).
await act(async () => {
fireEvent.change(select, { target: { value: "agent-b" } });
await flush();
});

expect(fetchMock).toHaveBeenCalledWith(
"/api/projects/prj-test/lead",
expect.objectContaining({
method: "PATCH",
body: JSON.stringify({ member_id: "agent-b" }),
}),
);
expect((screen.getByLabelText("Project lead") as HTMLSelectElement).value).toBe("agent-b");
});

it("offers a 'No lead' option that clears the lead via setLead(null)", async () => {
const fetchMock = mockFetch({
"/api/projects/prj-test/members": {
ok: true,
body: { items: [memberRow("agent-a")] },
},
"/api/agents": { ok: true, body: [agentA] },
"/api/agents/registry": { ok: true, body: [] },
"/api/projects/prj-test/lead": { ok: true, body: { ok: true, lead_member_id: null } },
});
vi.stubGlobal("fetch", fetchMock);

const project = {
...baseProject,
lead_member_id: "agent-a",
} as unknown as Project;

render(<ProjectMembers project={project} onChanged={vi.fn()} />);
await flush();

const select = screen.getByLabelText("Project lead") as HTMLSelectElement;
expect(select.value).toBe("agent-a");

await act(async () => {
fireEvent.change(select, { target: { value: "" } });
await flush();
});

expect(fetchMock).toHaveBeenCalledWith(
"/api/projects/prj-test/lead",
expect.objectContaining({
method: "PATCH",
body: JSON.stringify({ member_id: null }),
}),
);
});
});

const project = { id: "prj-test", name: "taOS", slug: "taos" } as unknown as Project;

// A consent-flow external agent: the member row keys on the canonical id, and
// the registry entry has an EMPTY handle (this is what the approve flow writes).
const externalMember = {
project_id: "prj-test",
member_id: "grok-taos-20260711-000736",
member_kind: "native",
role: "member",
is_lead: 0,
can_edit_canvas: 0,
};

const registryEntry = {
canonical_id: "grok-taos-20260711-000736",
handle: "",
display_name: "grok-taOS",
framework: "grok",
origin: "external-selfjoin",
status: "active",
};
80 changes: 70 additions & 10 deletions desktop/src/apps/ProjectsApp/ProjectMembers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ function MemberRow({
isExternal,
framework,
projectId,
isLead,
onRefresh,
onChanged,
}: {
Expand All @@ -85,6 +86,7 @@ function MemberRow({
isExternal?: boolean;
framework?: string;
projectId: string;
isLead?: boolean;
onRefresh: () => void;
onChanged: () => void;
}) {
Expand Down Expand Up @@ -115,7 +117,7 @@ function MemberRow({
{typeLabel}
</span>
)}
{!!member.is_lead && (
{isLead && (
<span className="ml-1 text-xs text-yellow-400 font-medium" aria-label="Lead agent">
★ Lead
</span>
Expand All @@ -129,36 +131,37 @@ function MemberRow({
{isAgent && (
<label
style={{ display: "inline-flex", alignItems: "center", gap: 6 }}
title="When off, this agent can add new elements but cannot modify or delete existing ones."
title="When on, this agent can read (list, stream, snapshot) the canvas."
>
<input
type="checkbox"
checked={!!member.can_edit_canvas}
checked={!!member.can_read_canvas}
aria-label={`Can read canvas for ${label}`}
onChange={async (e) => {
await canvasApi.setPermission(projectId, member.member_id, e.target.checked);
await canvasApi.setPermission(projectId, member.member_id, "read", e.target.checked);
onRefresh();
onChanged();
}}
/>
<span className="text-xs">Can edit canvas</span>
<span className="text-xs">Can read canvas</span>
</label>
)}
{isAgent && (
<label
style={{ display: "inline-flex", alignItems: "center", gap: 6 }}
title="Lead agents see all messages in the project channel, even without being @mentioned."
title="When off, this agent can add new elements but cannot modify or delete existing ones."
>
<input
type="checkbox"
checked={!!member.is_lead}
aria-label={`Toggle lead for ${label}`}
checked={!!member.can_edit_canvas}
aria-label={`Can edit canvas for ${label}`}
onChange={async (e) => {
await projectsApi.members.setLead(projectId, member.member_id, e.target.checked);
await canvasApi.setPermission(projectId, member.member_id, "edit", e.target.checked);
onRefresh();
onChanged();
}}
/>
<span className="text-xs">Lead</span>
<span className="text-xs">Can edit canvas</span>
</label>
)}
<button
Expand All @@ -184,6 +187,32 @@ export function ProjectMembers({ project, onChanged }: { project: Project; onCha
const [externalAgents, setExternalAgents] = useState<ExternalAgentSummary[]>([]);
const [externalRegistryLoaded, setExternalRegistryLoaded] = useState(false);
const [dialogOpen, setDialogOpen] = useState(false);
// D7: the Lead is an exclusive, project-level designation. We keep a local
// mirror of project.lead_member_id so the selector reflects changes instantly
// (the backend enforces the one-lead invariant structurally on write).
const [leadMemberId, setLeadMemberId] = useState<string | null>(project.lead_member_id ?? null);

useEffect(() => {
setLeadMemberId(project.lead_member_id ?? null);
}, [project.lead_member_id]);

const handleLeadChange = async (value: string) => {
const next = value || null;
setLeadMemberId(next);
try {
await projectsApi.setLead(project.id, next);
} catch {
// Revert on failure so the UI stays consistent with the server.
setLeadMemberId(project.lead_member_id ?? null);
}
onChanged();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: onChanged() fires even when the lead change failed

After the catch reverts local leadMemberId state, onChanged() is still called unconditionally, triggering an unnecessary parent refresh/flicker on failure. Consider calling onChanged() only on success (or moving it inside the try) so a failed request doesn't cause a spurious re-render.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

};

const labelFor = (m: ProjectMember): string => {
if (byId.has(m.member_id)) return formatMemberLabel(m.member_id, byId).label;
if (byHandle.has(m.member_id)) return formatExternalMemberLabel(m.member_id, byHandle).label;
return m.member_id;
};

const refresh = () =>
projectsApi.members.list(project.id).then(setMembers).catch(() => setMembers([]));
Expand Down Expand Up @@ -289,6 +318,11 @@ export function ProjectMembers({ project, onChanged }: { project: Project; onCha
return { mainMembers: main, externalMembers: external };
}, [members, byId, byHandle, externalRegistryLoaded]);

const leadOptions = useMemo(
() => [...mainMembers, ...externalMembers],
[mainMembers, externalMembers],
);

return (
<section>
<header className="flex justify-between mb-3">
Expand All @@ -301,6 +335,30 @@ export function ProjectMembers({ project, onChanged }: { project: Project; onCha
+ Add agent
</button>
</header>
<div className="mb-3 flex flex-wrap items-center gap-2">
<label htmlFor="project-lead-select" className="text-xs text-zinc-400">
Lead
</label>
<select
id="project-lead-select"
aria-label="Project lead"
value={leadMemberId ?? ""}
onChange={(e) => {
void handleLeadChange(e.target.value);
}}
className="text-xs bg-zinc-800 border border-zinc-700 rounded px-2 py-1"
>
<option value="">No lead</option>
{leadOptions.map((m) => (
<option key={m.member_id} value={m.member_id}>
{labelFor(m)}
</option>
))}
</select>
<span className="text-[10px] text-zinc-500">
exclusive per project
</span>
</div>
<ul className="space-y-1" aria-label="Project members">
{mainMembers.map((m) => {
const { label, emoji, hint } = formatMemberLabel(m.member_id, byId);
Expand All @@ -311,6 +369,7 @@ export function ProjectMembers({ project, onChanged }: { project: Project; onCha
label={label}
emoji={emoji}
hint={hint}
isLead={m.member_id === leadMemberId}
projectId={project.id}
onRefresh={refresh}
onChanged={onChanged}
Expand All @@ -330,6 +389,7 @@ export function ProjectMembers({ project, onChanged }: { project: Project; onCha
member={m}
label={label}
hint={hint}
isLead={m.member_id === leadMemberId}
isExternal
framework={byHandle.get(m.member_id)?.framework}
projectId={project.id}
Expand Down
11 changes: 9 additions & 2 deletions desktop/src/apps/ProjectsApp/canvas/canvas-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,15 +92,22 @@ export const canvasApi = {
return r.ok;
},

// Set a single canvas capability checkbox for an agent member. `flag` selects
// which capability to flip: "read" (can_read_canvas) or "edit" (can_edit_canvas).
// Both default OFF in the store, so the human ticks exactly what each agent
// may do. The backend permission PATCH accepts either field independently.
async setPermission(
projectId: string, agentId: string, canEdit: boolean,
projectId: string, agentId: string, flag: "read" | "edit", allowed: boolean,
): Promise<void> {
const body = flag === "read"
? { can_read_canvas: allowed }
: { can_edit_canvas: allowed };
const r = await fetch(
`/api/projects/${projectId}/canvas/permissions/${agentId}`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ can_edit_canvas: canEdit }),
body: JSON.stringify(body),
},
);
if (!r.ok) throw new Error(`setPermission failed: ${r.status}`);
Expand Down
Loading
Loading