fix: queen badge

This commit is contained in:
Richard Tang
2026-04-19 19:37:49 -07:00
parent 67d55e6cce
commit 93c0ef672a
7 changed files with 196 additions and 40 deletions
+2
View File
@@ -80,6 +80,7 @@ export interface DiscoverEntry {
name: string;
description: string;
category: string;
created_at?: string | null;
session_count: number;
run_count: number;
node_count: number;
@@ -87,6 +88,7 @@ export interface DiscoverEntry {
tags: string[];
last_active: string | null;
is_loaded: boolean;
icon?: string | null;
workers: WorkerEntry[];
}
+22 -27
View File
@@ -38,6 +38,8 @@ import {
workerIdFromStreamId,
} from "@/lib/chat-helpers";
type QueenPhase = "independent" | "incubating" | "working" | "reviewing";
export interface ChatMessage {
id: string;
agent: string;
@@ -59,7 +61,7 @@ export interface ChatMessage {
/** Epoch ms when this message was first created — used for ordering queen/worker interleaving */
createdAt?: number;
/** Queen phase active when this message was created */
phase?: "independent" | "incubating" | "working" | "reviewing";
phase?: QueenPhase;
/** Images attached to a user message */
images?: ImageContent[];
/** Backend node_id that produced this message — used for subagent grouping */
@@ -106,7 +108,7 @@ interface ChatPanelProps {
/** Called when user dismisses the pending question without answering */
onQuestionDismiss?: () => void;
/** Queen operating phase — shown as a tag on queen messages */
queenPhase?: "independent" | "incubating" | "working" | "reviewing";
queenPhase?: QueenPhase;
/** When false, queen messages omit the phase badge */
showQueenPhaseBadge?: boolean;
/** Context window usage for queen and workers */
@@ -151,6 +153,18 @@ interface ChatPanelProps {
const queenColor = "hsl(45,95%,58%)";
const workerColor = "hsl(220,60%,55%)";
function queenPhaseLabel(phase?: QueenPhase): string {
return phase ?? "independent";
}
function queenPhaseBadgeClass(phase?: QueenPhase): string {
if (phase === "incubating") {
// Honey-amber tint distinguishes spec incubation from the normal queen modes.
return "bg-amber-500/15 text-amber-500";
}
return "bg-primary/15 text-primary";
}
function getColor(_agent: string, role?: "queen" | "worker"): string {
if (role === "queen") return queenColor;
return workerColor;
@@ -357,7 +371,7 @@ function InlineAskUserBubble({
thread: string,
images?: ImageContent[],
) => void;
queenPhase?: "independent" | "incubating" | "working" | "reviewing";
queenPhase?: QueenPhase;
showQueenPhaseBadge?: boolean;
queenProfileId?: string | null;
}) {
@@ -455,23 +469,10 @@ function InlineAskUserBubble({
</span>
{(!isQueen || showQueenPhaseBadge) && (() => {
const effectivePhase = msg.phase ?? queenPhase;
const isIncubating = isQueen && effectivePhase === "incubating";
const badgeClass = isQueen
? isIncubating
// Honey-amber tint distinguishes incubating from the
// primary-tinted independent/working/reviewing badges.
? "bg-amber-500/15 text-amber-500"
: "bg-primary/15 text-primary"
? queenPhaseBadgeClass(effectivePhase)
: "bg-muted text-muted-foreground";
const label = isQueen
? effectivePhase === "working"
? "working"
: effectivePhase === "reviewing"
? "reviewing"
: effectivePhase === "incubating"
? "incubating"
: "independent"
: "Worker";
const label = isQueen ? queenPhaseLabel(effectivePhase) : "Worker";
return (
<span className={`text-[10px] font-medium px-1.5 py-0.5 rounded-md ${badgeClass}`}>
{label}
@@ -585,7 +586,7 @@ const MessageBubble = memo(
onColonyLinkClick,
}: {
msg: ChatMessage;
queenPhase?: "independent" | "incubating" | "working" | "reviewing";
queenPhase?: QueenPhase;
showQueenPhaseBadge?: boolean;
queenProfileId?: string | null;
queenAvatarUrl?: string | null;
@@ -778,17 +779,11 @@ const MessageBubble = memo(
<span
className={`text-[10px] font-medium px-1.5 py-0.5 rounded-md ${
isQueen
? "bg-primary/15 text-primary"
? queenPhaseBadgeClass(msg.phase ?? queenPhase)
: "bg-muted text-muted-foreground"
}`}
>
{isQueen
? (msg.phase ?? queenPhase) === "working"
? "working"
: (msg.phase ?? queenPhase) === "reviewing"
? "reviewing"
: "independent"
: "Worker"}
{isQueen ? queenPhaseLabel(msg.phase ?? queenPhase) : "Worker"}
</span>
)}
{msg.createdAt && (
@@ -1,5 +1,5 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { NavLink, useLocation, useNavigate } from "react-router-dom";
import { NavLink, useNavigate } from "react-router-dom";
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";
@@ -29,7 +29,6 @@ function SectionHeader({ children, onEdit }: { children: React.ReactNode; onEdit
export default function QueenProfilePanel({ queenId, colonies, onClose }: QueenProfilePanelProps) {
const navigate = useNavigate();
const location = useLocation();
const { queenProfiles, refresh } = useColony();
const summary = queenProfiles.find((q) => q.id === queenId);
const [profile, setProfile] = useState<QueenProfile | null>(null);
@@ -49,8 +48,6 @@ export default function QueenProfilePanel({ queenId, colonies, onClose }: QueenP
const [editSkills, setEditSkills] = useState("");
const [editAchievement, setEditAchievement] = useState("");
const alreadyInQueenPm = location.pathname === `/queen/${queenId}`;
useEffect(() => {
setLoading(true);
setProfile(null);
+37 -1
View File
@@ -1,5 +1,9 @@
import { describe, it, expect } from "vitest";
import { sseEventToChatMessage, formatAgentDisplayName } from "./chat-helpers";
import {
extractLastPhase,
sseEventToChatMessage,
formatAgentDisplayName,
} from "./chat-helpers";
import type { AgentEvent } from "@/api/types";
// ---------------------------------------------------------------------------
@@ -443,3 +447,35 @@ describe("formatAgentDisplayName", () => {
expect(formatAgentDisplayName("agent")).toBe("Agent");
});
});
// ---------------------------------------------------------------------------
// extractLastPhase
// ---------------------------------------------------------------------------
describe("extractLastPhase", () => {
it("keeps incubating as a valid queen phase", () => {
expect(
extractLastPhase([
makeEvent({
type: "queen_phase_changed",
data: { phase: "independent" },
}),
makeEvent({
type: "queen_phase_changed",
data: { phase: "incubating" },
}),
]),
).toBe("incubating");
});
it("reads phase metadata from node loop iterations", () => {
expect(
extractLastPhase([
makeEvent({
type: "node_loop_iteration",
data: { phase: "working" },
}),
]),
).toBe("working");
});
});
+7 -2
View File
@@ -463,8 +463,13 @@ export function replayEventsToMessages(
return [block, ...native];
}
type QueenPhase = "planning" | "building" | "staging" | "running" | "independent";
const VALID_PHASES = new Set<string>(["planning", "building", "staging", "running", "independent"]);
type QueenPhase = "independent" | "incubating" | "working" | "reviewing";
const VALID_PHASES = new Set<string>([
"independent",
"incubating",
"working",
"reviewing",
]);
/**
* Scan an array of persisted events and return the last queen phase seen,
+2 -6
View File
@@ -1,6 +1,6 @@
import { useState, useCallback, useRef, useEffect, useMemo } from "react";
import { useParams, useSearchParams } from "react-router-dom";
import { Loader2, Users, Plus } from "lucide-react";
import { Loader2, Plus } from "lucide-react";
import ChatPanel, {
type ChatMessage,
type ImageContent,
@@ -923,11 +923,7 @@ export default function QueenDM() {
isBusy={isTyping}
disabled={loading || !queenReady}
queenPhase={queenPhase}
// The DM is normally in `independent` phase, so the per-message
// badge would just be noise. Surface it once the phase moves
// (e.g. INCUBATING after start_incubating_colony approves) so
// the user immediately sees the queen is in a different mode.
showQueenPhaseBadge={queenPhase !== "independent"}
showQueenPhaseBadge
pendingQuestion={awaitingInput ? pendingQuestion : null}
pendingOptions={awaitingInput ? pendingOptions : null}
pendingQuestions={awaitingInput ? pendingQuestions : null}
+125
View File
@@ -512,3 +512,128 @@ async def test_fork_failure_keeps_materialized_skill(patched_home, monkeypatch)
installed = _colony_skill_path(patched_home, "will_fail", "durable-skill") / "SKILL.md"
assert installed.exists()
assert "hint" in payload
# ---------------------------------------------------------------------------
# triggers — inline schedule persisted to triggers.json
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_triggers_written_to_triggers_json(patched_home: Path, patched_fork: list[dict]) -> None:
"""A valid ``triggers`` arg is written to {colony_dir}/triggers.json."""
executor, _ = _make_executor()
triggers = [
{
"id": "daily-report",
"trigger_type": "timer",
"trigger_config": {"cron": "0 9 * * *"},
"task": "Generate the daily report",
},
{
"id": "github-webhook",
"trigger_type": "webhook",
"trigger_config": {"path": "/hooks/github"},
"task": "Process the github event",
"name": "GitHub webhook",
},
]
payload = await _call(
executor,
colony_name="scheduled",
task="t",
skill_name="scheduled-skill",
skill_description="d",
skill_body=_DEFAULT_BODY,
triggers=triggers,
)
assert payload.get("status") == "created", payload
triggers_path = patched_home / ".hive" / "colonies" / "scheduled" / "triggers.json"
assert triggers_path.exists()
written = json.loads(triggers_path.read_text(encoding="utf-8"))
assert len(written) == 2
assert written[0]["id"] == "daily-report"
assert written[0]["trigger_type"] == "timer"
assert written[0]["trigger_config"] == {"cron": "0 9 * * *"}
assert written[0]["task"] == "Generate the daily report"
# Unspecified name defaults to id; specified name is preserved.
assert written[0]["name"] == "daily-report"
assert written[1]["name"] == "GitHub webhook"
@pytest.mark.asyncio
async def test_triggers_omitted_does_not_write_triggers_json(
patched_home: Path, patched_fork: list[dict]
) -> None:
"""No triggers arg → no triggers.json (colony runs on-demand)."""
executor, _ = _make_executor()
payload = await _call(
executor,
colony_name="no_schedule",
task="t",
skill_name="plain-skill",
skill_description="d",
skill_body=_DEFAULT_BODY,
)
assert payload.get("status") == "created", payload
triggers_path = patched_home / ".hive" / "colonies" / "no_schedule" / "triggers.json"
assert not triggers_path.exists()
@pytest.mark.asyncio
async def test_triggers_invalid_cron_fails_before_fork(
patched_home: Path, patched_fork: list[dict]
) -> None:
"""A bad cron fails fast: no skill written, no fork call."""
executor, _ = _make_executor()
payload = await _call(
executor,
colony_name="bad_cron",
task="t",
skill_name="skill",
skill_description="d",
skill_body=_DEFAULT_BODY,
triggers=[
{
"id": "broken",
"trigger_type": "timer",
"trigger_config": {"cron": "not a cron"},
"task": "x",
}
],
)
assert "error" in payload
assert "cron" in payload["error"]
# Fork was not called, skill not materialized.
assert len(patched_fork) == 0
assert not (patched_home / ".hive" / "colonies" / "bad_cron" / ".hive" / "skills" / "skill").exists()
@pytest.mark.asyncio
async def test_triggers_missing_task_fails(patched_home: Path, patched_fork: list[dict]) -> None:
"""A trigger without a ``task`` is rejected before any write happens."""
executor, _ = _make_executor()
payload = await _call(
executor,
colony_name="no_task",
task="t",
skill_name="skill",
skill_description="d",
skill_body=_DEFAULT_BODY,
triggers=[
{
"id": "notask",
"trigger_type": "timer",
"trigger_config": {"interval_minutes": 5},
}
],
)
assert "error" in payload
assert "task" in payload["error"]
assert len(patched_fork) == 0