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:
@@ -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,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 }),
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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[] = [
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user