fix: queen badge
This commit is contained in:
@@ -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[];
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user