feat: colony creation, queen identity in colonies, and org chart improvements

- Colony creation: add "Create a Colony" button in queen DM (conversation header),
  queen profile panel, and sidebar with queen picker + goal input
- Queen identity in colonies: resolve queen profile name for colony chat messages,
  fix duplicate messages on refresh via SSE replay deduplication with restore cutoff
- Colony header: show colony name with Component icon, queen profile link preserved
- Org chart: colony detail drawer with metadata (start date, goal, status, stats),
  icon picker for colonies (16 icons, persisted to metadata.json), fixed queen card
  heights, fixed queen display order via shared sortQueenProfiles()
- Chat: add headerAction slot for inline buttons next to "Conversation" header
- Backend: PATCH /api/agents/metadata for colony icon, created_at in discover API
  with filesystem fallback, chat-helpers queen name passthrough for cold restore

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vincent Jiang
2026-04-19 18:55:01 -07:00
parent c17205a453
commit ed2e7125ac
14 changed files with 606 additions and 155 deletions
+17 -1
View File
@@ -47,6 +47,8 @@ class AgentEntry:
tool_count: int = 0
tags: list[str] = field(default_factory=list)
last_active: str | None = None
created_at: str | None = None
icon: str | None = None
workers: list[WorkerEntry] = field(default_factory=list)
@@ -209,13 +211,25 @@ def discover_agents() -> dict[str, list[AgentEntry]]:
name = config_fallback_name
desc = ""
# Read colony metadata for queen provenance
# Read colony metadata for queen provenance and timestamps
colony_queen_name = ""
colony_created_at: str | None = None
colony_icon: str | None = None
metadata_path = path / "metadata.json"
if metadata_path.exists():
try:
mdata = json.loads(metadata_path.read_text(encoding="utf-8"))
colony_queen_name = mdata.get("queen_name", "")
colony_created_at = mdata.get("created_at")
colony_icon = mdata.get("icon")
except Exception:
pass
# Fallback: use directory creation time if metadata lacks created_at
if not colony_created_at:
try:
from datetime import datetime, timezone
stat = path.stat()
colony_created_at = datetime.fromtimestamp(stat.st_birthtime, tz=timezone.utc).isoformat()
except Exception:
pass
@@ -256,6 +270,8 @@ def discover_agents() -> dict[str, list[AgentEntry]]:
tool_count=tool_count,
tags=[],
last_active=_get_last_active(path),
created_at=colony_created_at,
icon=colony_icon,
workers=worker_entries,
)
)
+37
View File
@@ -866,6 +866,8 @@ async def handle_discover(request: web.Request) -> web.Response:
"tool_count": entry.tool_count,
"tags": entry.tags,
"last_active": entry.last_active,
"created_at": entry.created_at,
"icon": entry.icon,
"is_loaded": str(entry.path.resolve()) in loaded_paths,
"workers": [w.to_dict() for w in entry.workers],
}
@@ -946,6 +948,40 @@ async def handle_reveal_session_folder(request: web.Request) -> web.Response:
return web.json_response({"path": str(folder)})
async def handle_update_colony_metadata(request: web.Request) -> web.Response:
"""PATCH /api/agents/metadata — update colony metadata (e.g. icon).
Body: {"agent_path": "...", "icon": "rocket"}
"""
try:
body = await request.json()
except Exception:
return web.json_response({"error": "Invalid JSON body"}, status=400)
agent_path = body.get("agent_path")
if not agent_path:
return web.json_response({"error": "agent_path is required"}, status=400)
try:
resolved = validate_agent_path(agent_path)
except ValueError as exc:
return web.json_response({"error": str(exc)}, status=400)
metadata_path = resolved / "metadata.json"
metadata: dict = {}
if metadata_path.exists():
try:
metadata = json.loads(metadata_path.read_text(encoding="utf-8"))
except Exception:
pass
if "icon" in body:
metadata["icon"] = body["icon"]
metadata_path.write_text(json.dumps(metadata, indent=2, ensure_ascii=False), encoding="utf-8")
return web.json_response({"ok": True})
# ------------------------------------------------------------------
# Route registration
# ------------------------------------------------------------------
@@ -956,6 +992,7 @@ def register_routes(app: web.Application) -> None:
# Discovery & agent management
app.router.add_get("/api/discover", handle_discover)
app.router.add_delete("/api/agents", handle_delete_agent)
app.router.add_patch("/api/agents/metadata", handle_update_colony_metadata)
# Session lifecycle
app.router.add_post("/api/sessions", handle_create_session)
+4
View File
@@ -7,4 +7,8 @@ export const agentsApi = {
/** Permanently delete an agent and all its sessions/files. */
deleteAgent: (agentPath: string) =>
api.delete<{ deleted: string }>("/agents", { agent_path: agentPath }),
/** Update colony metadata (e.g. icon). */
updateMetadata: (agentPath: string, updates: { icon?: string }) =>
api.patch<{ ok: boolean }>("/agents/metadata", { agent_path: agentPath, ...updates }),
};
+2 -3
View File
@@ -4,7 +4,7 @@ import { useColony } from "@/context/ColonyContext";
import { useHeaderActions } from "@/context/HeaderActionsContext";
import { useModel } from "@/context/ModelContext";
import { getQueenForAgent } from "@/lib/colony-registry";
import { Crown, KeyRound, Network, ChevronDown } from "lucide-react";
import { Crown, Component, KeyRound, Network, ChevronDown } from "lucide-react";
import SettingsModal from "@/components/SettingsModal";
function UserAvatarButton({ initials, onClick, avatarVersion }: { initials: string; onClick: () => void; avatarVersion: number }) {
@@ -63,15 +63,14 @@ export default function AppHeader({ onOpenQueenProfile }: AppHeaderProps) {
const colonyId = colonyMatch[1];
const colony = colonies.find((c) => c.id === colonyId);
title = colony?.name ?? colonyId;
// Show queen profile button when the colony has a linked queen profile
if (colony?.queenProfileId) {
const profile = queenProfiles.find((q) => q.id === colony.queenProfileId);
if (profile) {
queenIdForProfile = profile.id;
queenTitle = profile.title ?? null;
icon = <Crown className="w-4 h-4 text-primary" />;
}
}
icon = <Component className="w-4 h-4 text-primary" />;
} else if (queenMatch) {
const queenId = queenMatch[1];
const profile = queenProfiles.find((q) => q.id === queenId);
+31 -83
View File
@@ -120,6 +120,10 @@ interface ChatPanelProps {
queenProfileId?: string | null;
/** Queen ID — used to display the queen's avatar photo in messages */
queenId?: string;
/** Cumulative LLM token usage for this session */
tokenUsage?: { input: number; output: number };
/** Optional action element rendered on the right side of the "Conversation" header */
headerAction?: React.ReactNode;
}
const queenColor = "hsl(45,95%,58%)";
@@ -405,7 +409,7 @@ function InlineAskUserBubble({
style={isQueen && queenAvatarUrl ? undefined : {
backgroundColor: `${color}18`,
border: `1.5px solid ${color}35`,
boxShadow: isQueen ? `0 0 12px ${color}20` : undefined,
boxShadow: isQueen ? `0 0 6px ${color}10` : undefined,
}}
onClick={handleAvatarClick}
title={avatarTitle}
@@ -625,7 +629,7 @@ const MessageBubble = memo(
style={isQueen && queenAvatarUrl ? undefined : {
backgroundColor: `${color}18`,
border: `1.5px solid ${color}35`,
boxShadow: isQueen ? `0 0 12px ${color}20` : undefined,
boxShadow: isQueen ? `0 0 6px ${color}10` : undefined,
}}
onClick={handleAvatarClick}
title={avatarTitle}
@@ -711,6 +715,8 @@ export default function ChatPanel({
initialDraft,
queenProfileId,
queenId,
tokenUsage,
headerAction,
}: ChatPanelProps) {
const [input, setInput] = useState("");
const [pendingImages, setPendingImages] = useState<ImageContent[]>([]);
@@ -1073,6 +1079,7 @@ export default function ChatPanel({
<p className="text-[11px] text-muted-foreground font-medium uppercase tracking-wider">
Conversation
</p>
{headerAction && <div className="ml-auto">{headerAction}</div>}
</div>
{/* Messages */}
@@ -1164,7 +1171,7 @@ export default function ChatPanel({
style={queenAvatarUrl ? undefined : {
backgroundColor: `${queenColor}18`,
border: `1.5px solid ${queenColor}35`,
boxShadow: `0 0 12px ${queenColor}20`,
boxShadow: `0 0 6px ${queenColor}10`,
}}
>
<QueenAvatarIcon url={queenAvatarUrl} size={9} />
@@ -1219,90 +1226,31 @@ export default function ChatPanel({
<div ref={bottomRef} />
</div>
{/* Context window usage bar — sits between messages and input */}
{/* Context & token usage — compact inline stats */}
{(() => {
if (!contextUsage) return null;
const queenUsage = contextUsage["__queen__"];
const workerEntries = Object.entries(contextUsage).filter(
([k]) => k !== "__queen__",
);
const workerUsage =
workerEntries.length > 0
? workerEntries.reduce(
(best, [, v]) => (v.usagePct > best.usagePct ? v : best),
workerEntries[0][1],
)
: undefined;
if (!queenUsage && !workerUsage) return null;
const fmt = (tokens: number) => tokens >= 1000 ? `${(tokens / 1000).toFixed(1)}k` : String(tokens);
const color = (pct: number) => pct >= 90 ? "text-red-400" : pct >= 70 ? "text-orange-400" : "text-muted-foreground/50";
let queenUsage: ContextUsageEntry | undefined;
if (contextUsage) {
queenUsage = contextUsage["__queen__"];
}
const hasContext = !!queenUsage;
const hasTokens = tokenUsage && (tokenUsage.input > 0 || tokenUsage.output > 0);
if (!hasContext && !hasTokens) return null;
return (
<div className="flex items-center gap-3 mx-4 px-3 py-1 rounded-lg bg-muted/30 border border-border/20 group/ctx flex-shrink-0">
<div className="flex items-center justify-end gap-3 mx-4 px-2 py-0.5 flex-shrink-0 text-[10px] text-muted-foreground/50 tabular-nums">
{queenUsage && (
<div
className="flex items-center gap-2 flex-1 min-w-0"
title={`Queen: ${(queenUsage.estimatedTokens / 1000).toFixed(1)}k / ${(queenUsage.maxTokens / 1000).toFixed(0)}k tokens \u00b7 ${queenUsage.messageCount} messages`}
>
<Crown
className="w-3 h-3 flex-shrink-0"
style={{ color: "hsl(45,95%,58%)" }}
/>
<div className="flex-1 h-1.5 rounded-full bg-muted/50 overflow-hidden min-w-[60px]">
<div
className="h-full rounded-full transition-all duration-500 ease-out"
style={{
width: `${Math.min(queenUsage.usagePct, 100)}%`,
backgroundColor:
queenUsage.usagePct >= 90
? "hsl(0,65%,55%)"
: queenUsage.usagePct >= 70
? "hsl(35,90%,55%)"
: "hsl(45,95%,58%)",
}}
/>
</div>
<span className="text-[10px] text-muted-foreground/70 flex-shrink-0 tabular-nums">
<span className="group-hover/ctx:hidden">
{queenUsage.usagePct}%
</span>
<span className="hidden group-hover/ctx:inline">
{(queenUsage.estimatedTokens / 1000).toFixed(1)}k /{" "}
{(queenUsage.maxTokens / 1000).toFixed(0)}k
</span>
</span>
</div>
<span className={color(queenUsage.usagePct)} title={`${queenUsage.messageCount} messages`}>
Context: {fmt(queenUsage.estimatedTokens)}/{fmt(queenUsage.maxTokens)}
</span>
)}
{workerUsage && (
<div
className="flex items-center gap-2 flex-1 min-w-0"
title={`Worker: ${(workerUsage.estimatedTokens / 1000).toFixed(1)}k / ${(workerUsage.maxTokens / 1000).toFixed(0)}k tokens \u00b7 ${workerUsage.messageCount} messages`}
>
<Cpu
className="w-3 h-3 flex-shrink-0"
style={{ color: "hsl(220,60%,55%)" }}
/>
<div className="flex-1 h-1.5 rounded-full bg-muted/50 overflow-hidden min-w-[60px]">
<div
className="h-full rounded-full transition-all duration-500 ease-out"
style={{
width: `${Math.min(workerUsage.usagePct, 100)}%`,
backgroundColor:
workerUsage.usagePct >= 90
? "hsl(0,65%,55%)"
: workerUsage.usagePct >= 70
? "hsl(35,90%,55%)"
: "hsl(220,60%,55%)",
}}
/>
</div>
<span className="text-[10px] text-muted-foreground/70 flex-shrink-0 tabular-nums">
<span className="group-hover/ctx:hidden">
{workerUsage.usagePct}%
</span>
<span className="hidden group-hover/ctx:inline">
{(workerUsage.estimatedTokens / 1000).toFixed(1)}k /{" "}
{(workerUsage.maxTokens / 1000).toFixed(0)}k
</span>
</span>
</div>
{hasTokens && (
<span title="LLM tokens used this session (input + output)">
Tokens: {fmt(tokenUsage!.input + tokenUsage!.output)}
</span>
)}
</div>
);
@@ -1,10 +1,12 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { NavLink, useLocation, useNavigate } from "react-router-dom";
import { X, MessageSquare, Crown, ChevronRight, Briefcase, Award, Pencil, Check, Loader2, Camera } from "lucide-react";
import { X, MessageSquare, Crown, ChevronRight, Briefcase, Award, Pencil, Check, Loader2, Camera, Plus } from "lucide-react";
import { useColony } from "@/context/ColonyContext";
import { queensApi, type QueenProfile } from "@/api/queens";
import { executionApi } from "@/api/execution";
import { compressImage } from "@/lib/image-utils";
import type { Colony } from "@/types/colony";
import { slugToColonyId } from "@/lib/colony-registry";
interface QueenProfilePanelProps {
queenId: string;
@@ -112,6 +114,33 @@ export default function QueenProfilePanel({ queenId, colonies, onClose }: QueenP
}
};
// Colony creation
const [colonyDialogOpen, setColonyDialogOpen] = useState(false);
const [colonyName, setColonyName] = useState("");
const [colonyTask, setColonyTask] = useState("");
const [creatingColony, setCreatingColony] = useState(false);
const handleCreateColony = async () => {
const cname = colonyName.trim();
if (!cname || creatingColony) return;
setCreatingColony(true);
try {
// Create a fresh queen session, then fork it into a colony
const { session_id } = await queensApi.createNewSession(queenId, colonyTask.trim() || undefined);
await executionApi.colonySpawn(session_id, cname, colonyTask.trim() || undefined);
setColonyDialogOpen(false);
setColonyName("");
setColonyTask("");
refresh();
onClose();
navigate(`/colony/${slugToColonyId(cname)}`);
} catch (err) {
console.error("Failed to create colony:", err);
} finally {
setCreatingColony(false);
}
};
const name = profile?.name ?? summary?.name ?? "Queen";
const title = profile?.title ?? summary?.title ?? "";
@@ -268,13 +297,18 @@ export default function QueenProfilePanel({ queenId, colonies, onClose }: QueenP
</button>
</div>
{!alreadyInQueenPm && (
<div className="flex items-center gap-2 mb-6">
<button onClick={() => { navigate(`/queen/${queenId}`); onClose(); }}
className="w-full flex items-center justify-center gap-2 rounded-lg border border-border/60 py-2.5 text-sm font-medium text-foreground hover:bg-muted/40 mb-6">
className="flex-1 flex items-center justify-center gap-2 rounded-lg border border-border/60 py-2.5 text-sm font-medium text-foreground hover:bg-muted/40">
<MessageSquare className="w-4 h-4" />
Message {name}
Message
</button>
)}
<button onClick={() => setColonyDialogOpen(true)}
className="flex-1 flex items-center justify-center gap-2 rounded-lg border border-primary/30 bg-primary/[0.04] py-2.5 text-sm font-medium text-primary hover:bg-primary/[0.08]">
<Plus className="w-4 h-4" />
Create Colony
</button>
</div>
{profile?.summary && (
<div className="mb-6">
@@ -340,6 +374,44 @@ export default function QueenProfilePanel({ queenId, colonies, onClose }: QueenP
</>
)}
</div>
{/* Create Colony dialog */}
{colonyDialogOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/40" onClick={() => !creatingColony && setColonyDialogOpen(false)} />
<div className="relative bg-card border border-border/60 rounded-xl shadow-2xl w-full max-w-md p-6 space-y-4">
<h2 className="text-sm font-semibold text-foreground">Create Colony</h2>
<p className="text-[11px] text-muted-foreground">
Create a new colony managed by {name}. The queen will bootstrap it with tools and context.
</p>
<div className="space-y-3">
<div>
<label className="block text-[11px] font-medium text-muted-foreground mb-1">Colony name <span className="text-primary">*</span></label>
<input type="text" value={colonyName} autoFocus
onChange={(e) => setColonyName(e.target.value.toLowerCase().replace(/[^a-z0-9_]/g, ""))}
placeholder="e.g. research_team"
className="w-full rounded-md border border-border/60 bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground/50 focus:outline-none focus:ring-1 focus:ring-primary" />
</div>
<div>
<label className="block text-[11px] font-medium text-muted-foreground mb-1">Task <span className="text-muted-foreground/40">(optional)</span></label>
<input type="text" value={colonyTask} onChange={(e) => setColonyTask(e.target.value)}
placeholder="Describe what this colony should work on"
className="w-full rounded-md border border-border/60 bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground/50 focus:outline-none focus:ring-1 focus:ring-primary" />
</div>
</div>
<div className="flex justify-end gap-2 pt-2">
<button onClick={() => { setColonyDialogOpen(false); setColonyName(""); setColonyTask(""); }} disabled={creatingColony}
className="px-3 py-1.5 rounded-md text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-muted/50">
Cancel
</button>
<button onClick={handleCreateColony} disabled={creatingColony || !colonyName.trim()}
className="px-3 py-1.5 rounded-md text-xs font-medium bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50">
{creatingColony ? "Creating..." : "Create"}
</button>
</div>
</div>
</div>
)}
</aside>
);
}
+124 -15
View File
@@ -8,20 +8,54 @@ import {
Sparkles,
KeyRound,
ChevronDown,
Plus,
X,
Crown,
Loader2,
} from "lucide-react";
import SidebarColonyItem from "./SidebarColonyItem";
import SidebarQueenItem from "./SidebarQueenItem";
import { useColony } from "@/context/ColonyContext";
import { queensApi } from "@/api/queens";
import { executionApi } from "@/api/execution";
import { slugToColonyId, sortQueenProfiles } from "@/lib/colony-registry";
export default function Sidebar() {
const navigate = useNavigate();
const { colonies, queens, queenProfiles, sidebarCollapsed, setSidebarCollapsed } = useColony();
const { colonies, queens, queenProfiles, sidebarCollapsed, setSidebarCollapsed, refresh } = useColony();
const activeQueenIds = new Set(
queens.filter((q) => q.status === "online").map((q) => q.id),
);
const [coloniesExpanded, setColoniesExpanded] = useState(true);
const [queensExpanded, setQueensExpanded] = useState(true);
// Colony creation
const [createColonyOpen, setCreateColonyOpen] = useState(false);
const [newColonyQueen, setNewColonyQueen] = useState("");
const [newColonyName, setNewColonyName] = useState("");
const [newColonyGoal, setNewColonyGoal] = useState("");
const [creatingColony, setCreatingColony] = useState(false);
const handleCreateColony = async () => {
const cname = newColonyName.trim();
if (!cname || !newColonyQueen || creatingColony) return;
setCreatingColony(true);
try {
const { session_id } = await queensApi.createNewSession(newColonyQueen, newColonyGoal.trim() || undefined);
await executionApi.colonySpawn(session_id, cname, newColonyGoal.trim() || undefined);
setCreateColonyOpen(false);
setNewColonyQueen("");
setNewColonyName("");
setNewColonyGoal("");
refresh();
navigate(`/colony/${slugToColonyId(cname)}`);
} catch (err) {
console.error("Failed to create colony:", err);
} finally {
setCreatingColony(false);
}
};
// ── Resizable width ──────────────────────────────────────────────────
const MIN_WIDTH = 180;
const MAX_WIDTH = 400;
@@ -82,6 +116,7 @@ export default function Sidebar() {
}
return (
<>
<aside
className="flex-shrink-0 flex flex-col bg-sidebar-bg border-r border-sidebar-border h-full relative"
style={{ width }}
@@ -149,20 +184,29 @@ export default function Sidebar() {
{/* COLONIES section */}
<div className="flex-1 overflow-y-auto min-h-0">
<div className="py-2">
<button
onClick={() => setColoniesExpanded((v) => !v)}
className="flex items-center gap-1.5 px-4 py-1.5 w-full text-[11px] font-semibold text-sidebar-section-text uppercase tracking-wider hover:text-foreground transition-colors"
>
<ChevronDown
className={`w-3 h-3 transition-transform ${coloniesExpanded ? "" : "-rotate-90"}`}
/>
<span>Colonies</span>
{colonies.length > 0 && (
<span className="ml-auto text-[10px] bg-sidebar-item-hover rounded-full px-1.5 py-0.5 font-medium">
{colonies.length}
</span>
)}
</button>
<div className="flex items-center px-4 py-1.5">
<button
onClick={() => setColoniesExpanded((v) => !v)}
className="flex items-center gap-1.5 flex-1 text-[11px] font-semibold text-sidebar-section-text uppercase tracking-wider hover:text-foreground transition-colors"
>
<ChevronDown
className={`w-3 h-3 transition-transform ${coloniesExpanded ? "" : "-rotate-90"}`}
/>
<span>Colonies</span>
{colonies.length > 0 && (
<span className="text-[10px] bg-sidebar-item-hover rounded-full px-1.5 py-0.5 font-medium">
{colonies.length}
</span>
)}
</button>
<button
onClick={() => setCreateColonyOpen(true)}
className="p-0.5 rounded text-sidebar-section-text hover:text-foreground hover:bg-sidebar-item-hover transition-colors"
title="Create colony"
>
<Plus className="w-3.5 h-3.5" />
</button>
</div>
{coloniesExpanded && (
<div className="flex flex-col gap-0.5 mt-0.5">
{colonies.map((colony) => (
@@ -207,5 +251,70 @@ export default function Sidebar() {
</div>
</div>
</aside>
{/* Create Colony modal */}
{createColonyOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/40" onClick={() => !creatingColony && setCreateColonyOpen(false)} />
<div className="relative bg-card border border-border/60 rounded-xl shadow-2xl w-full max-w-md p-6 space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold text-foreground">Create Colony</h2>
<button onClick={() => setCreateColonyOpen(false)} className="p-1 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/50">
<X className="w-4 h-4" />
</button>
</div>
<div className="space-y-3">
<div>
<label className="block text-[11px] font-medium text-muted-foreground mb-1">Queen Bee <span className="text-primary">*</span></label>
<div className="grid grid-cols-2 gap-1.5 max-h-[160px] overflow-y-auto">
{sortQueenProfiles(queenProfiles).map((q) => (
<button key={q.id} onClick={() => setNewColonyQueen(q.id)}
className={`flex items-center gap-2 rounded-lg border px-3 py-2 text-left text-xs transition-colors ${
newColonyQueen === q.id
? "border-primary/40 bg-primary/[0.06] text-primary"
: "border-border/50 text-foreground hover:border-primary/30"
}`}>
<Crown className="w-3 h-3 flex-shrink-0" />
<div className="min-w-0">
<p className="font-medium truncate">{q.name}</p>
<p className="text-[10px] text-muted-foreground truncate">{q.title}</p>
</div>
</button>
))}
</div>
</div>
<div>
<label className="block text-[11px] font-medium text-muted-foreground mb-1">Colony Name <span className="text-primary">*</span></label>
<input type="text" value={newColonyName}
onChange={(e) => setNewColonyName(e.target.value.toLowerCase().replace(/[^a-z0-9_]/g, ""))}
placeholder="e.g. research_team" autoFocus
className="w-full rounded-md border border-border/60 bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground/50 focus:outline-none focus:ring-1 focus:ring-primary" />
</div>
<div>
<label className="block text-[11px] font-medium text-muted-foreground mb-1">Goal <span className="text-muted-foreground/40">(optional)</span></label>
<textarea value={newColonyGoal} onChange={(e) => setNewColonyGoal(e.target.value)}
placeholder="Describe what this colony should work on" rows={3}
className="w-full rounded-md border border-border/60 bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground/50 focus:outline-none focus:ring-1 focus:ring-primary resize-none" />
</div>
</div>
<div className="flex justify-end gap-2 pt-2">
<button onClick={() => { setCreateColonyOpen(false); setNewColonyQueen(""); setNewColonyName(""); setNewColonyGoal(""); }}
disabled={creatingColony}
className="px-3 py-1.5 rounded-md text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-muted/50">
Cancel
</button>
<button onClick={handleCreateColony} disabled={creatingColony || !newColonyName.trim() || !newColonyQueen}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50">
{creatingColony ? <><Loader2 className="w-3 h-3 animate-spin" /> Creating...</> : "Create"}
</button>
</div>
</div>
</div>
)}
</>
);
}
+5 -1
View File
@@ -182,8 +182,8 @@ export function ColonyProvider({ children }: { children: ReactNode }) {
const liveInfo = liveSessionMap.get(slug);
const sessionId = liveInfo?.sessionId ?? null;
const isRunning = sessionId !== null;
const queenProfileId = liveInfo?.queenId ?? historyQueenMap.get(slug) ?? null;
const queenName = agent.workers?.[0]?.queen_name || "";
const queenProfileId = liveInfo?.queenId ?? historyQueenMap.get(slug) ?? (queenName || null);
return {
id: colonyId,
@@ -198,6 +198,10 @@ export function ColonyProvider({ children }: { children: ReactNode }) {
sessionCount: agent.session_count,
runCount: agent.run_count,
queenName,
createdAt: agent.created_at ?? null,
lastActive: agent.last_active ?? null,
task: agent.workers?.[0]?.task || "",
icon: agent.icon ?? null,
};
});
+7 -6
View File
@@ -263,9 +263,11 @@ export function replayEvent(
event: AgentEvent,
thread: string,
agentDisplayName: string | undefined,
queenDisplayName?: string,
): ChatMessage[] {
const streamId = event.stream_id;
const isQueen = streamId === "queen";
const effectiveName = isQueen ? (queenDisplayName || agentDisplayName) : agentDisplayName;
const role: "queen" | "worker" = isQueen ? "queen" : "worker";
const turnKey = streamId;
const currentTurn = state.turnCounters[turnKey] ?? 0;
@@ -313,7 +315,7 @@ export function replayEvent(
const allDone = tools.length > 0 && tools.every((t) => t.done);
out.push({
id: pillId,
agent: agentDisplayName || event.node_id || "Agent",
agent: effectiveName || event.node_id || "Agent",
agentColor: "",
content: JSON.stringify({ tools, allDone }),
timestamp: "",
@@ -340,11 +342,9 @@ export function replayEvent(
.filter((t) => t.streamId === streamId)
.map((t) => ({ name: t.name, done: t.done }));
const allDone = tools.length > 0 && tools.every((t) => t.done);
// Re-emit the SAME pill id with updated content. Caller upserts
// by id, so this replaces the row from tool_call_started.
out.push({
id: tracked.msgId,
agent: agentDisplayName || event.node_id || "Agent",
agent: effectiveName || event.node_id || "Agent",
agentColor: "",
content: JSON.stringify({ tools, allDone }),
timestamp: "",
@@ -364,7 +364,7 @@ export function replayEvent(
const msg = sseEventToChatMessage(
event,
thread,
agentDisplayName,
effectiveName,
state.turnCounters[turnKey] ?? 0,
);
if (msg) {
@@ -384,12 +384,13 @@ export function replayEventsToMessages(
events: AgentEvent[],
thread: string,
agentDisplayName: string | undefined,
queenDisplayName?: string,
): ChatMessage[] {
const state = newReplayState();
// Upsert by id — later emissions for the same pill replace earlier ones.
const byId = new Map<string, ChatMessage>();
for (const evt of events) {
for (const m of replayEvent(state, evt, thread, agentDisplayName)) {
for (const m of replayEvent(state, evt, thread, agentDisplayName, queenDisplayName)) {
byId.set(m.id, m);
}
}
+21
View File
@@ -114,6 +114,27 @@ export function getColonyColor(slug: string): string {
return COLONY_COLORS[slug] || "hsl(45,95%,58%)";
}
/** Fixed display order for queen profiles */
export const QUEEN_DISPLAY_ORDER: string[] = [
"queen_technology",
"queen_operations",
"queen_growth",
"queen_finance_fundraising",
"queen_talent",
"queen_product_strategy",
"queen_brand_design",
"queen_legal",
];
/** Sort queen profiles by fixed display order */
export function sortQueenProfiles<T extends { id: string }>(profiles: T[]): T[] {
return [...profiles].sort((a, b) => {
const ia = QUEEN_DISPLAY_ORDER.indexOf(a.id);
const ib = QUEEN_DISPLAY_ORDER.indexOf(b.id);
return (ia === -1 ? 999 : ia) - (ib === -1 ? 999 : ib);
});
}
/** Pre-defined templates for the home page */
export const TEMPLATES: Template[] = [
{
+32 -3
View File
@@ -56,6 +56,7 @@ async function restoreSessionMessages(
sessionId: string,
thread: string,
agentDisplayName: string,
queenDisplayName?: string,
): Promise<SessionRestoreResult> {
try {
const { events, truncated, total, returned } =
@@ -81,7 +82,7 @@ async function restoreSessionMessages(
}
}
const messages = replayEventsToMessages(events, thread, agentDisplayName);
const messages = replayEventsToMessages(events, thread, agentDisplayName, queenDisplayName);
// Stamp the latest phase on every queen message so the UI's
// phase-badge rendering matches what the live path would have
// displayed at the time of the refresh.
@@ -204,7 +205,7 @@ function defaultAgentState(): AgentState {
export default function ColonyChat() {
const { colonyId } = useParams<{ colonyId: string }>();
const location = useLocation();
const { colonies, markVisited, refresh: refreshColonies } = useColony();
const { colonies, queenProfiles, markVisited, refresh: refreshColonies } = useColony();
const { setActions } = useHeaderActions();
const { toggleColonyWorkers } = useColonyWorkers();
@@ -219,7 +220,14 @@ export default function ColonyChat() {
const colony = colonies.find((c) => c.id === colonyId);
const agentPath = colony?.agentPath ?? routeState.agentPath ?? "";
const slug = agentPath ? agentSlug(agentPath) : "";
const queenInfo = getQueenForAgent(slug);
const fallbackQueenInfo = getQueenForAgent(slug);
// Resolve queen name from the linked queen profile, falling back to registry
const linkedQueenProfile = colony?.queenProfileId
? queenProfiles.find((q) => q.id === colony.queenProfileId)
: null;
const queenInfo = linkedQueenProfile
? { name: linkedQueenProfile.name, role: linkedQueenProfile.title }
: fallbackQueenInfo;
const colonyName = colony?.name ?? colonyId ?? "Colony";
// Mark colony as visited when navigating to it
@@ -298,6 +306,9 @@ export default function ColonyChat() {
agentStateRef.current = agentState;
const turnCounterRef = useRef<Record<string, number>>({});
// Timestamp of the latest restored message — SSE events older than this
// are duplicates from the ring-buffer replay and should be skipped.
const restoreCutoffRef = useRef<number>(0);
// Maps tool_use_id → the pill message ID and tool name that was created for it.
// Survives turn counter resets so deferred completions (e.g. ask_user) can
// find and update the correct pill even after the counter changes.
@@ -465,6 +476,7 @@ export default function ColonyChat() {
coldRestoreId,
agentPath,
displayName,
queenInfo.name,
);
}
@@ -490,6 +502,7 @@ export default function ColonyChat() {
session.session_id,
agentPath,
displayName,
queenInfo.name,
);
if (restored.messages.length > 0) {
restoredMessages = restored.messages;
@@ -507,6 +520,7 @@ export default function ColonyChat() {
session.session_id,
agentPath,
displayName,
queenInfo.name,
);
restoredMessages = restored.messages;
restoredPhase = restored.restoredPhase;
@@ -516,6 +530,9 @@ export default function ColonyChat() {
if (restoredMessages.length > 0) {
restoredMessages.sort((a, b) => (a.createdAt ?? 0) - (b.createdAt ?? 0));
setMessages(restoredMessages);
// Record the latest restored timestamp so SSE replay duplicates are skipped
const maxTs = Math.max(...restoredMessages.map((m) => m.createdAt ?? 0));
restoreCutoffRef.current = maxTs;
}
const initialPhase = resolveInitialColonyPhase({
@@ -568,6 +585,7 @@ export default function ColonyChat() {
queenPhaseRef.current = "independent";
queenIterTextRef.current = {};
suppressIntroRef.current = false;
restoreCutoffRef.current = 0;
loadingRef.current = false;
loadSession();
}
@@ -590,6 +608,16 @@ export default function ColonyChat() {
const eventCreatedAt = event.timestamp
? new Date(event.timestamp).getTime()
: Date.now();
// Skip SSE replay events that were already restored from history
if (
restoreCutoffRef.current > 0 &&
eventCreatedAt <= restoreCutoffRef.current &&
(event.type === "client_output_delta" || event.type === "llm_text_delta" || event.type === "client_input_received")
) {
return;
}
const shouldMarkQueenReady = isQueen && !state.queenReady;
switch (event.type) {
@@ -1399,6 +1427,7 @@ export default function ColonyChat() {
contextUsage={agentState.contextUsage}
supportsImages={agentState.queenSupportsImages}
queenProfileId={colony?.queenProfileId ?? null}
queenId={colony?.queenProfileId ?? undefined}
/>
</div>
+222 -24
View File
@@ -1,10 +1,24 @@
import { useState, useCallback, useRef, useEffect } from "react";
import { NavLink } from "react-router-dom";
import { User } from "lucide-react";
import { useNavigate } from "react-router-dom";
import {
User, Component, X, Calendar, Target, Activity, ArrowRight, Clock,
Rocket, Globe, Mail, Search, Shield, TrendingUp, Briefcase, Code,
Database, FileText, MessageSquare, Zap, BarChart3, Users, Bot,
type LucideIcon,
} from "lucide-react";
import { useColony } from "@/context/ColonyContext";
import { agentsApi } from "@/api/agents";
import type { QueenProfileSummary, Colony } from "@/types/colony";
import { getColonyIcon } from "@/lib/colony-registry";
import QueenProfilePanel from "@/components/QueenProfilePanel";
import { sortQueenProfiles } from "@/lib/colony-registry";
const COLONY_ICONS: Record<string, LucideIcon> = {
component: Component, rocket: Rocket, globe: Globe, mail: Mail,
search: Search, shield: Shield, trending: TrendingUp, briefcase: Briefcase,
code: Code, database: Database, file: FileText, message: MessageSquare,
zap: Zap, chart: BarChart3, users: Users, bot: Bot,
};
const COLONY_ICON_KEYS = Object.keys(COLONY_ICONS);
/* ── User avatar (CEO card) ──────────────────────────────────────────── */
@@ -25,22 +39,175 @@ function UserAvatar({ initials, avatarVersion }: { initials: string; avatarVersi
);
}
/* ── Colony tag (clickable link to colony chat) ───────────────────────── */
/* ── Colony tag ──────────────────────────────────────────────────────── */
function ColonyTag({ colony }: { colony: Colony }) {
const Icon = getColonyIcon(colony.queenId);
function ColonyTag({ colony, onSelect }: { colony: Colony; onSelect: () => void }) {
const Icon = (colony.icon && COLONY_ICONS[colony.icon]) || Component;
return (
<NavLink
to={`/colony/${colony.id}`}
className="flex items-center gap-1.5 rounded-lg border border-border/50 bg-muted/40 px-2.5 py-1.5 text-xs text-muted-foreground hover:border-primary/30 hover:text-foreground transition-colors"
<button
onClick={onSelect}
className="flex items-center gap-1.5 rounded-lg border border-border/50 bg-muted/40 px-2.5 py-1.5 text-xs text-muted-foreground hover:border-primary/30 hover:text-foreground transition-colors w-full text-left"
>
<Icon className="w-3 h-3 flex-shrink-0" />
<Icon className="w-3 h-3 flex-shrink-0 text-primary/60" />
<span className="truncate">{colony.name}</span>
</NavLink>
</button>
);
}
/* ── Queen card in the org grid ───────────────────────────────────────── */
/* ── Colony detail drawer ────────────────────────────────────────────── */
function ColonyDetailPanel({ colony, queenName, onClose, onIconChange }: {
colony: Colony; queenName: string; onClose: () => void;
onIconChange: (colonyId: string, icon: string) => void;
}) {
const navigate = useNavigate();
const [iconPickerOpen, setIconPickerOpen] = useState(false);
const formatDate = (iso: string | null) => {
if (!iso) return "—";
try {
const d = new Date(iso);
return d.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" })
+ " at " + d.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" });
} catch { return "—"; }
};
const formatRelative = (iso: string | null) => {
if (!iso) return null;
try {
const diff = Date.now() - new Date(iso).getTime();
const mins = Math.floor(diff / 60000);
if (mins < 1) return "just now";
if (mins < 60) return `${mins}m ago`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h ago`;
const days = Math.floor(hrs / 24);
return `${days}d ago`;
} catch { return null; }
};
const currentIconKey = colony.icon || "component";
const CurrentIcon = COLONY_ICONS[currentIconKey] || Component;
const handlePickIcon = async (key: string) => {
setIconPickerOpen(false);
onIconChange(colony.id, key);
await agentsApi.updateMetadata(colony.agentPath, { icon: key }).catch(() => {});
};
return (
<aside className="w-[320px] min-w-[320px] max-w-[320px] flex-shrink-0 border-l border-border/60 bg-card overflow-y-auto overflow-x-hidden overscroll-contain">
{/* Header */}
<div className="flex items-center justify-between px-5 py-3.5 border-b border-border/60">
<div className="flex items-center gap-2 text-sm font-semibold text-foreground">
<Component className="w-4 h-4 text-primary" />
Colony Details
</div>
<button onClick={onClose} className="p-1 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/60">
<X className="w-4 h-4" />
</button>
</div>
<div className="px-5 py-6">
{/* Icon + Name */}
<div className="mb-6 text-center">
<div className="relative inline-block">
<button
onClick={() => setIconPickerOpen(!iconPickerOpen)}
className="w-12 h-12 rounded-xl bg-primary/10 flex items-center justify-center mx-auto mb-3 hover:bg-primary/20 transition-colors"
title="Change icon"
>
<CurrentIcon className="w-6 h-6 text-primary" />
</button>
{iconPickerOpen && (
<div className="absolute top-14 left-1/2 -translate-x-1/2 bg-card border border-border/60 rounded-lg shadow-xl z-20 p-2 w-[200px]">
<p className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider mb-2 px-1">Choose icon</p>
<div className="grid grid-cols-4 gap-1">
{COLONY_ICON_KEYS.map((key) => {
const Icon = COLONY_ICONS[key];
return (
<button key={key} onClick={() => handlePickIcon(key)}
className={`w-10 h-10 rounded-lg flex items-center justify-center ${key === currentIconKey ? "bg-primary/15 text-primary" : "text-muted-foreground hover:bg-muted/50 hover:text-foreground"}`}>
<Icon className="w-4.5 h-4.5" />
</button>
);
})}
</div>
</div>
)}
</div>
<h3 className="text-base font-semibold text-foreground">{colony.name}</h3>
{queenName && <p className="text-xs text-muted-foreground mt-0.5">Managed by {queenName}</p>}
</div>
{/* Go to colony */}
<button
onClick={() => navigate(`/colony/${colony.id}`)}
className="w-full flex items-center justify-center gap-2 rounded-lg bg-primary text-primary-foreground py-2.5 text-sm font-medium hover:bg-primary/90 mb-6"
>
Open Colony
<ArrowRight className="w-4 h-4" />
</button>
{/* Metadata */}
<div className="space-y-4">
<div>
<h4 className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-1.5">Start Date</h4>
<div className="flex items-center gap-2 text-sm text-foreground">
<Calendar className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
{formatDate(colony.createdAt)}
</div>
</div>
<div>
<h4 className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-1.5">Project Goal</h4>
<div className="flex items-start gap-2 text-sm text-foreground/80 min-w-0">
<Target className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0 mt-0.5" />
<p className="leading-relaxed break-words min-w-0">{colony.task || colony.description || "No goal specified"}</p>
</div>
</div>
<div>
<h4 className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-1.5">Current Status</h4>
<div className="flex items-center gap-2 text-sm">
<Activity className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
<span className={`inline-flex items-center gap-1.5 ${colony.status === "running" ? "text-emerald-500" : "text-muted-foreground"}`}>
<span className={`w-1.5 h-1.5 rounded-full ${colony.status === "running" ? "bg-emerald-500" : "bg-muted-foreground/40"}`} />
{colony.status === "running" ? "Running" : "Idle"}
</span>
</div>
</div>
{colony.lastActive && (
<div>
<h4 className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-1.5">Last Active</h4>
<div className="flex items-center gap-2 text-sm text-foreground">
<Clock className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
{formatRelative(colony.lastActive) || formatDate(colony.lastActive)}
</div>
</div>
)}
<div>
<h4 className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-1.5">Stats</h4>
<div className="grid grid-cols-2 gap-2">
<div className="rounded-lg bg-muted/30 px-3 py-2 text-center">
<p className="text-lg font-semibold text-foreground">{colony.sessionCount}</p>
<p className="text-[10px] text-muted-foreground">Sessions</p>
</div>
<div className="rounded-lg bg-muted/30 px-3 py-2 text-center">
<p className="text-lg font-semibold text-foreground">{colony.runCount}</p>
<p className="text-[10px] text-muted-foreground">Runs</p>
</div>
</div>
</div>
</div>
</div>
</aside>
);
}
/* ── Queen avatar ────────────────────────────────────────────────────── */
function QueenAvatar({ queenId, name, size = "w-11 h-11" }: { queenId: string; name: string; size?: string }) {
const [hasAvatar, setHasAvatar] = useState(true);
@@ -56,26 +223,30 @@ function QueenAvatar({ queenId, name, size = "w-11 h-11" }: { queenId: string; n
);
}
/* ── Queen card in the org grid ───────────────────────────────────────── */
function QueenCard({
queen,
colonies,
selected,
onSelect,
onSelectColony,
}: {
queen: QueenProfileSummary;
colonies: Colony[];
selected: boolean;
onSelect: () => void;
onSelectColony: (colony: Colony) => void;
}) {
return (
<div className="flex flex-col items-center w-[140px] flex-shrink-0">
{/* Vertical stub from horizontal bar */}
<div className="w-px h-6 bg-border" />
{/* Queen card */}
{/* Queen card — fixed height so all cards align */}
<button
onClick={onSelect}
className={`group flex flex-col items-center rounded-xl border bg-card p-4 w-full transition-all duration-200 text-center ${
className={`group flex flex-col items-center justify-center rounded-xl border bg-card p-4 w-full h-[130px] transition-all duration-200 text-center ${
selected
? "border-primary/40 bg-primary/[0.04] ring-1 ring-primary/20"
: "border-border/60 hover:border-primary/30 hover:bg-primary/[0.03]"
@@ -84,10 +255,10 @@ function QueenCard({
<div className="mb-2.5">
<QueenAvatar queenId={queen.id} name={queen.name} />
</div>
<span className="text-sm font-semibold text-foreground group-hover:text-primary transition-colors">
<span className="text-sm font-semibold text-foreground group-hover:text-primary transition-colors line-clamp-1">
{queen.name}
</span>
<span className="text-xs text-muted-foreground mt-0.5">
<span className="text-xs text-muted-foreground mt-0.5 line-clamp-1">
{queen.title}
</span>
</button>
@@ -98,7 +269,7 @@ function QueenCard({
<div className="w-px h-4 bg-border" />
<div className="flex flex-col gap-1.5 w-full">
{colonies.map((colony) => (
<ColonyTag key={colony.id} colony={colony} />
<ColonyTag key={colony.id} colony={colony} onSelect={() => onSelectColony(colony)} />
))}
</div>
</>
@@ -109,9 +280,12 @@ function QueenCard({
/* ── Main org chart page ──────────────────────────────────────────────── */
// Fixed left-to-right order for queen cards
export default function OrgChart() {
const { queenProfiles, colonies, userProfile, userAvatarVersion } = useColony();
const { queenProfiles: unsortedQueenProfiles, colonies, userProfile, userAvatarVersion } = useColony();
const queenProfiles = sortQueenProfiles(unsortedQueenProfiles);
const [selectedQueenId, setSelectedQueenId] = useState<string | null>(null);
const [selectedColony, setSelectedColony] = useState<Colony | null>(null);
// Pan & zoom state
const [zoom, setZoom] = useState(1);
@@ -167,6 +341,21 @@ export default function OrgChart() {
.toUpperCase()
.slice(0, 2);
const handleSelectColony = (colony: Colony) => {
setSelectedQueenId(null);
setSelectedColony(selectedColony?.id === colony.id ? null : colony);
};
const handleSelectQueen = (queenId: string) => {
setSelectedColony(null);
setSelectedQueenId(selectedQueenId === queenId ? null : queenId);
};
// Resolve queen name for colony panel
const colonyQueenName = selectedColony?.queenProfileId
? (queenProfiles.find((q) => q.id === selectedColony.queenProfileId)?.name ?? "")
: "";
return (
<div className="flex-1 flex overflow-hidden">
{/* Main chart area — pannable canvas */}
@@ -234,11 +423,8 @@ export default function OrgChart() {
queen={queen}
colonies={coloniesByQueen.get(queen.id) ?? []}
selected={selectedQueenId === queen.id}
onSelect={() =>
setSelectedQueenId(
selectedQueenId === queen.id ? null : queen.id,
)
}
onSelect={() => handleSelectQueen(queen.id)}
onSelectColony={handleSelectColony}
/>
))}
</div>
@@ -247,7 +433,7 @@ export default function OrgChart() {
</div>
</div>
{/* Profile side panel */}
{/* Queen profile side panel */}
{selectedQueenId && (
<QueenProfilePanel
queenId={selectedQueenId}
@@ -255,6 +441,18 @@ export default function OrgChart() {
onClose={() => setSelectedQueenId(null)}
/>
)}
{/* Colony detail side panel */}
{selectedColony && (
<ColonyDetailPanel
colony={selectedColony}
queenName={colonyQueenName}
onClose={() => setSelectedColony(null)}
onIconChange={(colonyId, icon) => {
setSelectedColony((prev) => prev && prev.id === colonyId ? { ...prev, icon } : prev);
}}
/>
)}
</div>
);
}
+23 -14
View File
@@ -1,6 +1,6 @@
import { useState, useCallback, useRef, useEffect, useMemo } from "react";
import { useParams, useSearchParams } from "react-router-dom";
import { Loader2, Users } from "lucide-react";
import { Loader2, Users, Plus } from "lucide-react";
import ChatPanel, {
type ChatMessage,
type ImageContent,
@@ -45,6 +45,7 @@ export default function QueenDM() {
{ id: string; prompt: string; options?: string[] }[] | null
>(null);
const [awaitingInput, setAwaitingInput] = useState(false);
const [tokenUsage, setTokenUsage] = useState({ input: 0, output: 0 });
const [, setActiveToolCalls] = useState<
Record<string, { name: string; done: boolean }>
>({});
@@ -84,6 +85,7 @@ export default function QueenDM() {
setAwaitingInput(false);
setActiveToolCalls({});
setQueenPhase("independent");
setTokenUsage({ input: 0, output: 0 });
setInitialDraft(null);
turnCounterRef.current = 0;
toolUseToPillRef.current = {};
@@ -378,15 +380,6 @@ export default function QueenDM() {
if (!queenId) return;
setActions(
<>
<button
onClick={() => setCloneDialogOpen(true)}
disabled={!sessionId}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors flex-shrink-0 disabled:opacity-50"
title="Clone queen to a colony"
>
<Users className="w-3.5 h-3.5" />
Clone Colony
</button>
<QueenSessionSwitcher
sessions={historySessions}
currentSessionId={sessionId}
@@ -439,6 +432,11 @@ export default function QueenDM() {
case "llm_turn_complete":
turnCounterRef.current++;
setActiveToolCalls({});
if (event.data) {
const inp = (event.data.input_tokens as number) || 0;
const out = (event.data.output_tokens as number) || 0;
setTokenUsage((prev) => ({ input: prev.input + inp, output: prev.output + out }));
}
break;
case "client_output_delta":
@@ -856,6 +854,17 @@ export default function QueenDM() {
initialDraft={initialDraft}
queenProfileId={queenId ?? null}
queenId={queenId}
tokenUsage={tokenUsage}
headerAction={
<button
onClick={() => setCloneDialogOpen(true)}
disabled={!sessionId}
className="flex items-center gap-1 px-2.5 py-1 rounded-md text-[11px] font-medium text-primary hover:bg-primary/10 transition-colors disabled:opacity-40"
>
<Plus className="w-3 h-3" />
Create a Colony
</button>
}
/>
</div>
@@ -867,11 +876,11 @@ export default function QueenDM() {
/>
<div className="relative bg-card border border-border/60 rounded-xl shadow-2xl w-full max-w-md p-6 space-y-4">
<h2 className="text-sm font-semibold text-foreground">
Fork to Colony
Create a Colony
</h2>
<p className="text-[11px] text-muted-foreground">
Forks the queen's full session into a new colony. Multiple
sessions can run against it in parallel.
Create a new colony from this queen's session. The colony inherits
the queen's tools, context, and conversation history.
</p>
<div className="space-y-3">
<div>
@@ -922,7 +931,7 @@ export default function QueenDM() {
disabled={spawning || !cloneColonyName.trim()}
className="px-3 py-1.5 rounded-md text-xs font-medium bg-primary text-primary-foreground hover:bg-primary/90 transition-colors disabled:opacity-50"
>
{spawning ? "Forking..." : "Fork"}
{spawning ? "Creating..." : "Create"}
</button>
</div>
</div>
+4
View File
@@ -11,6 +11,10 @@ export interface Colony {
sessionCount: number;
runCount: number;
queenName: string;
createdAt: string | null;
lastActive: string | null;
task: string;
icon: string | null;
}
export interface QueenBee {