Merge pull request #7132 from vincentjiang777/feat/colony-session-transfer

feat: redesign configuration UI for sidebar, prompts, skills, and tools
This commit is contained in:
RichardTang-Aden
2026-04-24 19:02:03 -07:00
committed by GitHub
6 changed files with 204 additions and 160 deletions
+3 -3
View File
@@ -210,18 +210,18 @@ def _catalog_from_live_session(session: Any) -> dict[str, list[dict[str, Any]]]:
return {}
mcp_names = getattr(phase_state, "mcp_tool_names_all", set()) or set()
independent_tools = getattr(phase_state, "independent_tools", []) or []
result: dict[str, list[dict[str, Any]]] = {"(unknown)": []}
result: dict[str, list[dict[str, Any]]] = {"MCP Tools": []}
for tool in independent_tools:
if tool.name not in mcp_names:
continue
result["(unknown)"].append(
result["MCP Tools"].append(
{
"name": tool.name,
"description": tool.description,
"input_schema": tool.parameters,
}
)
return result if result["(unknown)"] else {}
return result if result["MCP Tools"] else {}
server_map = getattr(registry, "_mcp_server_tools", {}) or {}
tools_by_name = {t.name: t for t in registry.get_tools().values()}
+14 -14
View File
@@ -166,12 +166,19 @@ export default function Sidebar() {
<Network className="w-4 h-4" />
<span>Org Chart</span>
</button>
<button
onClick={() => navigate("/credentials")}
className="flex items-center gap-2.5 px-3 py-1.5 rounded-md text-sm text-foreground/70 hover:bg-sidebar-item-hover hover:text-foreground transition-colors"
>
<KeyRound className="w-4 h-4" />
<span>Credentials</span>
</button>
<button
onClick={() => setLibraryExpanded((v) => !v)}
className="flex items-center gap-2.5 px-3 py-1.5 rounded-md text-sm text-foreground/70 hover:bg-sidebar-item-hover hover:text-foreground transition-colors"
>
<Library className="w-4 h-4" />
<span className="flex-1 text-left">Library</span>
<span className="flex-1 text-left">Configuration</span>
<ChevronDown
className={`w-3.5 h-3.5 transition-transform ${
libraryExpanded ? "" : "-rotate-90"
@@ -180,18 +187,18 @@ export default function Sidebar() {
</button>
{libraryExpanded && (
<>
<button
onClick={() => navigate("/skills-library")}
className="flex items-center gap-2.5 pl-9 pr-3 py-1.5 rounded-md text-sm text-foreground/70 hover:bg-sidebar-item-hover hover:text-foreground transition-colors"
>
<span>Skills</span>
</button>
<button
onClick={() => navigate("/prompt-library")}
className="flex items-center gap-2.5 pl-9 pr-3 py-1.5 rounded-md text-sm text-foreground/70 hover:bg-sidebar-item-hover hover:text-foreground transition-colors"
>
<span>Prompts</span>
</button>
<button
onClick={() => navigate("/skills-library")}
className="flex items-center gap-2.5 pl-9 pr-3 py-1.5 rounded-md text-sm text-foreground/70 hover:bg-sidebar-item-hover hover:text-foreground transition-colors"
>
<span>Skills</span>
</button>
<button
onClick={() => navigate("/tool-library")}
className="flex items-center gap-2.5 pl-9 pr-3 py-1.5 rounded-md text-sm text-foreground/70 hover:bg-sidebar-item-hover hover:text-foreground transition-colors"
@@ -200,13 +207,6 @@ export default function Sidebar() {
</button>
</>
)}
<button
onClick={() => navigate("/credentials")}
className="flex items-center gap-2.5 px-3 py-1.5 rounded-md text-sm text-foreground/70 hover:bg-sidebar-item-hover hover:text-foreground transition-colors"
>
<KeyRound className="w-4 h-4" />
<span>Credentials</span>
</button>
</div>
{/* COLONIES section */}
+76 -100
View File
@@ -213,6 +213,12 @@ export default function ToolsEditor({
};
}, [subjectKey, fetchSnapshot]);
const allMcpNames = useMemo(() => {
const s = new Set<string>();
data?.mcp_servers.forEach((srv) => srv.tools.forEach((t) => s.add(t.name)));
return s;
}, [data]);
const dirty = useMemo(() => {
const a = draftAllowed;
const b = baselineRef.current;
@@ -223,11 +229,27 @@ export default function ToolsEditor({
return false;
}, [draftAllowed]);
const allMcpNames = useMemo(() => {
const s = new Set<string>();
data?.mcp_servers.forEach((srv) => srv.tools.forEach((t) => s.add(t.name)));
return s;
}, [data]);
const applyResult = (updated: string[] | null, isRoleDefault: boolean) => {
baselineRef.current = updated === null ? null : new Set(updated);
setDraftAllowed(updated === null ? null : new Set(updated));
if (data) {
const u = updated === null ? null : new Set(updated);
setData({
...data,
enabled_mcp_tools: updated,
is_role_default: isRoleDefault,
mcp_servers: data.mcp_servers.map((srv) => ({
...srv,
tools: srv.tools.map((t) => ({
...t,
enabled: u === null ? true : u.has(t.name),
})),
})),
});
}
setSavedRecently(true);
setTimeout(() => setSavedRecently(false), 2500);
};
const toggleOne = (name: string, next: boolean) => {
setDraftAllowed((prev) => {
@@ -249,41 +271,7 @@ export default function ToolsEditor({
});
};
const handleDraftAllowAll = () => setDraftAllowed(null);
const handleResetToRoleDefault = async () => {
if (!resetToRoleDefault) return;
setSaving(true);
setSaveError(null);
try {
const result = await resetToRoleDefault();
const updated = result.enabled_mcp_tools;
baselineRef.current = updated === null ? null : new Set(updated);
setDraftAllowed(updated === null ? null : new Set(updated));
if (data) {
const u = updated === null ? null : new Set(updated);
setData({
...data,
enabled_mcp_tools: updated,
is_role_default: true,
mcp_servers: data.mcp_servers.map((srv) => ({
...srv,
tools: srv.tools.map((t) => ({
...t,
enabled: u === null ? true : u.has(t.name),
})),
})),
});
}
setSavedRecently(true);
setTimeout(() => setSavedRecently(false), 2500);
} catch (e: unknown) {
const err = e as { body?: { error?: string } };
setSaveError(err.body?.error || "Reset failed");
} finally {
setSaving(false);
}
};
const handleAllowAll = () => setDraftAllowed(null);
const handleCancel = () => {
const baseline = baselineRef.current;
@@ -295,29 +283,17 @@ export default function ToolsEditor({
setSaving(true);
setSaveError(null);
try {
// Only send tool names the server knows about (MCP tools).
// The draft may contain lifecycle/synthetic names from the
// baseline — strip those to avoid "Unknown MCP tool name" errors.
const payload =
draftAllowed === null ? null : Array.from(draftAllowed).sort();
draftAllowed === null
? null
: Array.from(draftAllowed)
.filter((name) => allMcpNames.has(name))
.sort();
const result = await saveAllowlist(payload);
const updated = result.enabled_mcp_tools;
baselineRef.current = updated === null ? null : new Set(updated);
setDraftAllowed(updated === null ? null : new Set(updated));
if (data) {
const u = updated === null ? null : new Set(updated);
setData({
...data,
enabled_mcp_tools: updated,
is_role_default: false,
mcp_servers: data.mcp_servers.map((srv) => ({
...srv,
tools: srv.tools.map((t) => ({
...t,
enabled: u === null ? true : u.has(t.name),
})),
})),
});
}
setSavedRecently(true);
setTimeout(() => setSavedRecently(false), 2500);
applyResult(result.enabled_mcp_tools, false);
} catch (e: unknown) {
const err = e as { body?: { error?: string; unknown?: string[] } };
const extra = err.body?.unknown
@@ -329,6 +305,21 @@ export default function ToolsEditor({
}
};
const handleResetToRoleDefault = async () => {
if (!resetToRoleDefault) return;
setSaving(true);
setSaveError(null);
try {
const result = await resetToRoleDefault();
applyResult(result.enabled_mcp_tools, true);
} catch (e: unknown) {
const err = e as { body?: { error?: string } };
setSaveError(err.body?.error || "Reset failed");
} finally {
setSaving(false);
}
};
if (loading) {
return (
<div className="flex items-center gap-2 text-xs text-muted-foreground py-3">
@@ -423,7 +414,7 @@ export default function ToolsEditor({
return (
<CollapsibleGroup
key={srv.name}
title={srv.name}
title={srv.name === "(unknown)" ? "MCP Tools" : srv.name}
count={srv.tools.length}
badge={`${enabledInServer}/${srv.tools.length}`}
expanded={!!expanded[srv.name]}
@@ -457,65 +448,50 @@ export default function ToolsEditor({
);
})}
<div className="flex items-center gap-2 pt-3">
<div className="flex items-center gap-2 pt-3 flex-wrap">
{/* Primary actions */}
<button
onClick={handleSave}
disabled={!dirty || saving}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-md bg-primary text-primary-foreground text-xs font-medium hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed"
>
{saving ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : (
<Check className="w-3 h-3" />
)}
{saving ? <Loader2 className="w-3 h-3 animate-spin" /> : <Check className="w-3 h-3" />}
{saving ? "Saving…" : "Save"}
</button>
<button
onClick={handleCancel}
disabled={!dirty || saving}
className="px-3 py-1.5 rounded-md text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-muted/30 disabled:opacity-50"
className="px-3 py-1.5 rounded-md border border-border/60 text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-muted/30 disabled:opacity-50 disabled:cursor-not-allowed"
>
Cancel
</button>
{/* Status */}
{savedRecently && !dirty && (
<span className="text-[11px] text-green-500 flex items-center gap-1">
<Check className="w-3 h-3" /> Saved
</span>
)}
<div className="ml-auto flex items-center gap-3">
{data.is_role_default !== undefined && (
<span
className={`text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded ${
data.is_role_default
? "bg-muted/40 text-muted-foreground"
: "bg-primary/15 text-primary"
}`}
title={
data.is_role_default
? "Using the default allowlist for this role."
: "Custom allowlist saved by you."
}
>
{data.is_role_default ? "Role default" : "Custom"}
</span>
)}
{resetToRoleDefault && !data.is_role_default && (
{dirty && !saving && (
<span className="text-[11px] text-amber-500">Unsaved changes</span>
)}
{/* Quick actions */}
<div className="ml-auto flex items-center gap-2">
<button
onClick={handleAllowAll}
disabled={saving || draftAllowed === null}
className="px-3 py-1.5 rounded-md border border-border/60 text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-muted/30 disabled:opacity-50 disabled:cursor-not-allowed"
>
Allow all
</button>
{resetToRoleDefault && (
<button
onClick={handleResetToRoleDefault}
disabled={saving}
className="text-[11px] text-muted-foreground hover:text-foreground underline underline-offset-2 disabled:opacity-50"
disabled={saving || !!data.is_role_default}
className="px-3 py-1.5 rounded-md border border-border/60 text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-muted/30 disabled:opacity-50 disabled:cursor-not-allowed"
>
Reset to role default
</button>
)}
{draftAllowed !== null && (
<button
onClick={handleDraftAllowAll}
disabled={saving}
className="text-[11px] text-muted-foreground hover:text-foreground underline underline-offset-2 disabled:opacity-50"
title="Draft 'allow all' — click Save to persist."
>
Allow all
Reset to defaults
</button>
)}
</div>
+59 -22
View File
@@ -140,13 +140,26 @@ export default function PromptLibrary() {
promptsApi.list().then((r) => setCustomPrompts(r.prompts)).catch(() => {});
}, []);
// Merge built-in + custom prompts
const allPrompts = useMemo(() => [...customPrompts, ...prompts], [customPrompts]);
// Filtered custom (my) prompts
const filteredCustom = useMemo(() => {
let result: (Prompt | CustomPrompt)[] = customPrompts;
if (selectedCategory && selectedCategory !== "custom") {
result = result.filter((p) => p.category === selectedCategory);
}
if (searchQuery.trim()) {
const query = searchQuery.toLowerCase();
result = result.filter(
(p) => p.title.toLowerCase().includes(query) || p.content.toLowerCase().includes(query),
);
}
return result;
}, [customPrompts, searchQuery, selectedCategory]);
const filteredPrompts = useMemo(() => {
let result = allPrompts;
// Filtered built-in (community) prompts
const filteredBuiltIn = useMemo(() => {
let result: Prompt[] = prompts;
if (selectedCategory === "custom") {
result = result.filter((p) => "custom" in p && p.custom);
result = [];
} else if (selectedCategory) {
result = result.filter((p) => p.category === selectedCategory);
}
@@ -157,13 +170,13 @@ export default function PromptLibrary() {
);
}
return result;
}, [allPrompts, searchQuery, selectedCategory]);
}, [searchQuery, selectedCategory]);
// Reset page when filters change
useEffect(() => setPage(0), [searchQuery, selectedCategory]);
const totalPages = Math.max(1, Math.ceil(filteredPrompts.length / PAGE_SIZE));
const pagedPrompts = filteredPrompts.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
const totalPages = Math.max(1, Math.ceil(filteredBuiltIn.length / PAGE_SIZE));
const pagedBuiltIn = filteredBuiltIn.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
const handleUsePrompt = (content: string, category: string) => {
const queenId = categoryToQueen[category];
@@ -196,7 +209,7 @@ export default function PromptLibrary() {
Prompt Library
</h2>
<span className="text-xs text-muted-foreground">
{allPrompts.length} prompts across {promptCategories.length + (customCount > 0 ? 1 : 0)} categories
{customCount > 0 ? `${customCount} custom · ` : ""}{prompts.length} community prompts
</span>
</div>
<button onClick={() => setAddModalOpen(true)}
@@ -240,23 +253,47 @@ export default function PromptLibrary() {
{/* Prompts grid */}
<div className="flex-1 overflow-y-auto p-6">
{pagedPrompts.length > 0 ? (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{pagedPrompts.map((prompt) => (
<PromptCard
key={typeof prompt.id === "string" ? prompt.id : `builtin-${prompt.id}`}
prompt={prompt}
onUse={handleUsePrompt}
onDelete={"custom" in prompt && prompt.custom ? () => handleDeletePrompt(prompt.id as string) : undefined}
/>
))}
</div>
) : (
{filteredCustom.length === 0 && pagedBuiltIn.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-center">
<Sparkles className="w-10 h-10 text-muted-foreground/30 mb-3" />
<p className="text-sm text-muted-foreground">No prompts found</p>
<p className="text-xs text-muted-foreground/60 mt-1">Try adjusting your search or category filter</p>
</div>
) : (
<>
{/* My Prompts section */}
{filteredCustom.length > 0 && (
<div className="mb-8">
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-3">My Prompts</h3>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{filteredCustom.map((prompt) => (
<PromptCard
key={prompt.id as string}
prompt={prompt}
onUse={handleUsePrompt}
onDelete={"custom" in prompt && prompt.custom ? () => handleDeletePrompt(prompt.id as string) : undefined}
/>
))}
</div>
</div>
)}
{/* Community Prompts section */}
{pagedBuiltIn.length > 0 && selectedCategory !== "custom" && (
<div>
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-3">Community Prompts</h3>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{pagedBuiltIn.map((prompt) => (
<PromptCard
key={`builtin-${prompt.id}`}
prompt={prompt}
onUse={handleUsePrompt}
/>
))}
</div>
</div>
)}
</>
)}
</div>
@@ -264,7 +301,7 @@ export default function PromptLibrary() {
{totalPages > 1 && (
<div className="px-6 py-3 border-t border-border/60 flex items-center justify-between">
<span className="text-xs text-muted-foreground">
{page * PAGE_SIZE + 1}{Math.min((page + 1) * PAGE_SIZE, filteredPrompts.length)} of {filteredPrompts.length}
{page * PAGE_SIZE + 1}{Math.min((page + 1) * PAGE_SIZE, filteredBuiltIn.length)} of {filteredBuiltIn.length}
</span>
<div className="flex items-center gap-1">
<button onClick={() => setPage((p) => Math.max(0, p - 1))} disabled={page === 0}
+47 -17
View File
@@ -17,7 +17,7 @@ import {
} from "lucide-react";
import { queensApi } from "@/api/queens";
import { coloniesApi, type ColonySummary } from "@/api/colonies";
import { slugToDisplayName } from "@/lib/colony-registry";
import { slugToDisplayName, sortQueenProfiles } from "@/lib/colony-registry";
import { ApiError } from "@/api/client";
import {
skillsApi,
@@ -72,7 +72,7 @@ export default function SkillsLibrary() {
<div className="flex items-baseline gap-3 mb-3">
<h2 className="text-lg font-semibold text-foreground flex items-center gap-2">
<Library className="w-5 h-5 text-primary" />
Skills Library
Skills Configuration
</h2>
<span className="text-xs text-muted-foreground">
Curate which skills each queen and colony exposes, upload your own, or browse the full catalog.
@@ -141,8 +141,9 @@ function QueensTab() {
queensApi
.list()
.then((r) => {
setQueens(r.queens);
if (r.queens.length > 0) setSelected((prev) => prev ?? r.queens[0].id);
const sorted = sortQueenProfiles(r.queens);
setQueens(sorted);
if (sorted.length > 0) setSelected((prev) => prev ?? sorted[0].id);
})
.catch((e: Error) => setError(e.message || "Failed to load queens"));
}, []);
@@ -354,17 +355,46 @@ function SkillsPerScopeSection({
<p className="text-sm text-muted-foreground">No skills match your filter.</p>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{filtered.map((row) => (
<SkillCard
key={row.name}
row={row}
onToggle={() => toggle(row)}
onOpen={() => setDetailName(row.name)}
onRemove={row.deletable ? () => remove(row) : undefined}
/>
))}
</div>
{(() => {
const active = filtered.filter((r) => r.enabled);
const inactive = filtered.filter((r) => !r.enabled);
return (
<>
{active.length > 0 && (
<div className="mb-6">
<h4 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-3">Active</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{active.map((row) => (
<SkillCard
key={row.name}
row={row}
onToggle={() => toggle(row)}
onOpen={() => setDetailName(row.name)}
onRemove={row.deletable ? () => remove(row) : undefined}
/>
))}
</div>
</div>
)}
{inactive.length > 0 && (
<div>
<h4 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-3">Inactive</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{inactive.map((row) => (
<SkillCard
key={row.name}
row={row}
onToggle={() => toggle(row)}
onOpen={() => setDetailName(row.name)}
onRemove={row.deletable ? () => remove(row) : undefined}
/>
))}
</div>
</div>
)}
</>
);
})()}
<CreateSkillModal
open={createOpen}
@@ -430,9 +460,9 @@ function CatalogTab() {
<div className="flex-1" />
<button
onClick={() => setUploadOpen(true)}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-md border border-border/60 bg-card text-sm font-medium text-foreground hover:bg-muted/50"
className="flex items-center gap-1.5 px-3 py-1.5 rounded-md bg-primary/10 text-primary text-sm font-medium hover:bg-primary/20"
>
<Upload className="w-3.5 h-3.5" /> Upload
<Upload className="w-3.5 h-3.5" /> Upload Skill
</button>
</div>
+5 -4
View File
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from "react";
import { Wrench, Crown, Network, Server, Loader2, AlertCircle } from "lucide-react";
import { queensApi } from "@/api/queens";
import { coloniesApi, type ColonySummary } from "@/api/colonies";
import { slugToDisplayName } from "@/lib/colony-registry";
import { slugToDisplayName, sortQueenProfiles } from "@/lib/colony-registry";
import QueenToolsSection from "@/components/QueenToolsSection";
import ColonyToolsSection from "@/components/ColonyToolsSection";
import McpServersPanel from "@/components/McpServersPanel";
@@ -19,7 +19,7 @@ export default function ToolLibrary() {
<div className="flex items-baseline gap-3 mb-3">
<h2 className="text-lg font-semibold text-foreground flex items-center gap-2">
<Wrench className="w-5 h-5 text-primary" />
Tool Library
Tool Configuration
</h2>
<span className="text-xs text-muted-foreground">
Curate which tools each queen and colony can call, and register your own MCP servers.
@@ -88,8 +88,9 @@ function QueensTab() {
queensApi
.list()
.then((r) => {
setQueens(r.queens);
if (r.queens.length > 0) setSelected((prev) => prev ?? r.queens[0].id);
const sorted = sortQueenProfiles(r.queens);
setQueens(sorted);
if (sorted.length > 0) setSelected((prev) => prev ?? sorted[0].id);
})
.catch((e: Error) => setError(e.message || "Failed to load queens"));
}, []);