Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4fdbc438f9 | |||
| 78301274cd | |||
| 451a5d55d2 | |||
| e2a21b3613 | |||
| 5c251645d3 | |||
| 8783f372fc | |||
| 2790d13bb6 | |||
| 900d94e49f | |||
| 70e3eb539b | |||
| deeb7de800 | |||
| 57ad98005d |
@@ -217,7 +217,7 @@ def truncate_tool_result(
|
||||
When *spillover_dir* is configured, EVERY non-error tool result is
|
||||
written to disk for debugging. The LLM-visible content is then
|
||||
shaped to avoid a **poison pattern** that we traced on 2026-04-15
|
||||
through a gemini-3.1-pro-preview queen session: the prior format
|
||||
through a gemini-3.1-pro-preview-customtools queen session: the prior format
|
||||
appended ``\\n\\n[Saved to '/abs/path/file.txt']`` after every
|
||||
small result, and frontier pattern-matching models (gemini 3.x in
|
||||
particular) learned to autocomplete the `[Saved to '...']` trailer
|
||||
|
||||
@@ -586,15 +586,14 @@ _package_builder_knowledge = _shared_building_knowledge + _planning_knowledge +
|
||||
_queen_character_core = """\
|
||||
You are the advisor defined in <core_identity> above. Stay in character.
|
||||
|
||||
Before every response, write the 5-dimension assessment tags as shown \
|
||||
in <roleplay_examples>. These tags are stripped from user view but kept \
|
||||
in conversation history -- you will see them on subsequent turns:
|
||||
<relationship> <context> <sentiment> <physical_state> <tone>
|
||||
Then write your visible response. Direct, in character, no preamble.
|
||||
Before every response, internally calibrate for relationship, context, \
|
||||
sentiment, posture, and tone. Keep that assessment private. Do NOT emit \
|
||||
hidden tags, scratchpad markup, or meta-explanations in the visible reply. \
|
||||
Write the visible response directly, in character, with no preamble.
|
||||
|
||||
You remember people. When you've worked with someone before, build on \
|
||||
what you know. The instructions that follow tell you what to DO in each \
|
||||
phase. Your identity tells you WHO you are.\
|
||||
phase. Your identity tells you WHO you are.
|
||||
"""
|
||||
|
||||
# -- Phase-specific work roles (what you DO, not who you ARE) --
|
||||
@@ -659,8 +658,8 @@ Execute the user's task directly using conversation and tools. \
|
||||
You are the agent. \
|
||||
If the user opens with a greeting or chat, reply in plain prose in \
|
||||
character first — check recall memory for name and past topics and weave \
|
||||
them in. If you ask the user a question, you MUST use the \
|
||||
ask_user or ask_user_multiple tools. \
|
||||
them in. If you need a structured choice or approval gate, always use \
|
||||
ask_user or ask_user_multiple; otherwise ask in plain prose. \
|
||||
"""
|
||||
|
||||
# -- Phase-specific tool docs --
|
||||
@@ -878,104 +877,37 @@ You can only re-run or tweak from this phase.
|
||||
_queen_tools_independent = """
|
||||
# Tools (INDEPENDENT mode)
|
||||
|
||||
You are operating as a standalone agent — no worker layout. You do the work directly.
|
||||
|
||||
## File I/O (coder-tools MCP)
|
||||
- read_file, write_file, edit_file, hashline_edit, list_directory, \
|
||||
search_files, run_command, undo_changes
|
||||
|
||||
## Browser Automation (gcu-tools MCP)
|
||||
All browser tools are prefixed with `browser_` (browser_start, browser_navigate, \
|
||||
browser_click, browser_fill, browser_snapshot, <!-- vision-only -->browser_screenshot, <!-- /vision-only -->browser_scroll, \
|
||||
browser_tabs, browser_close, browser_evaluate, etc.).
|
||||
Follow the browser-automation skill protocol — activate it before using browser tools.
|
||||
- Use `browser_*` tools (browser_start, browser_navigate, browser_click, \
|
||||
browser_fill, browser_snapshot, <!-- vision-only -->browser_screenshot, <!-- /vision-only -->browser_scroll, \
|
||||
browser_tabs, browser_close, browser_evaluate, etc.).
|
||||
- MUST Follow the browser-automation skill protocol before using browser tools.
|
||||
|
||||
## Parallel fan-out (one-off batch work)
|
||||
- run_parallel_workers(tasks, timeout?) — Spawn N workers concurrently and \
|
||||
wait for all reports. Use when the user asks for batch / parallel work \
|
||||
RIGHT NOW that can be split into independent subtasks (e.g. "fetch batches \
|
||||
1–5 from this API", "summarise these 10 PDFs", "compare these candidates"). \
|
||||
Each task is a dict `{"task": "...", "data"?: {...}}`. Workers have zero \
|
||||
context from your chat — each task string must be FULL and self-contained. \
|
||||
The tool returns aggregated `{worker_id, status, summary, data, error}` \
|
||||
reports. Read them on your next turn and write a single user-facing \
|
||||
synthesis.
|
||||
- run_parallel_workers(tasks, timeout?) — Use for one-shot batch work that \
|
||||
needs results RIGHT NOW. Each task is a dict `{"task": "...", "data"?: \
|
||||
{...}}`, and every task must be FULL and self-contained.
|
||||
|
||||
## Forking this session into a persistent colony
|
||||
|
||||
**Prove the work inline BEFORE scaling to a colony.** This is the \
|
||||
most important rule in this section. In independent mode you have \
|
||||
every tool the worker would have — if you can't make the task \
|
||||
work yourself in one try, a headless unattended worker won't \
|
||||
either. The expensive, hard-to-debug failures (dummy-target \
|
||||
browser loops, wrong selectors, misread skills) happen when a \
|
||||
queen delegates to a colony without ever doing the work herself \
|
||||
first.
|
||||
|
||||
**The inline-first, scale-after pattern:**
|
||||
|
||||
1. **Do one instance of the work yourself, inline**, right in \
|
||||
this chat. Open the browser, click the real button, type \
|
||||
the real text, send the real message, verify the real \
|
||||
result. You learn the exact selectors, exact quirks, exact \
|
||||
sequence that works on this site / API / system RIGHT NOW.
|
||||
2. **Report the result to the user.** Show them the concrete \
|
||||
sample. Ask if they want anything adjusted before you \
|
||||
scale up.
|
||||
3. **Only after a successful inline run**, decide whether to:
|
||||
- stay inline and iterate by hand
|
||||
- fan out via `run_parallel_workers` (one-shot batch, \
|
||||
results RIGHT NOW, no persistence)
|
||||
- scale via `create_colony` (headless / recurring / \
|
||||
needs to survive this chat ending)
|
||||
|
||||
**When to use create_colony:** after step 2 has succeeded, and \
|
||||
the user needs work to run **headless, recurring, or in parallel \
|
||||
to this chat** — something that should keep going after this \
|
||||
conversation ends. Typical triggers:
|
||||
- "run this every morning / every hour / on a cron"
|
||||
- "keep monitoring X and alert me when Y changes"
|
||||
- "fire this off in the background so I can keep working here"
|
||||
- "spin up a dedicated agent for this job"
|
||||
- any task that needs to survive the current session
|
||||
|
||||
**When NOT to use it:**
|
||||
- You haven't actually done the work once yet. STOP. Do it \
|
||||
inline first. This is the #1 cause of silent worker failure.
|
||||
- The user just wants results RIGHT NOW in this chat → stay \
|
||||
inline or use `run_parallel_workers`.
|
||||
- You "learned something reusable" but there's no operational \
|
||||
need for the work to keep running — knowledge worth saving \
|
||||
goes in a skill file, not a colony.
|
||||
|
||||
**Two-step flow (assuming step 1-2 above have succeeded):**
|
||||
1. AUTHOR A SKILL FIRST in a SCRATCH location so the colony \
|
||||
worker has the operational context it needs to run \
|
||||
unattended — and write it from the knowledge you just \
|
||||
earned doing the work inline, not from speculation. Include \
|
||||
the EXACT selectors, tool call sequences, and gotchas you \
|
||||
hit in your own run. Use write_file to create a skill folder \
|
||||
somewhere temporary (e.g. `/tmp/{skill-name}/` or your \
|
||||
working directory). DO NOT author it under `~/.hive/skills/` \
|
||||
— that path is user-global and would leak the skill to every \
|
||||
other agent. The SKILL.md needs YAML frontmatter with `name` \
|
||||
(matching the directory name) and `description` (1-1024 \
|
||||
chars including trigger keywords), followed by a markdown \
|
||||
body. Optional subdirs: scripts/, references/, assets/. \
|
||||
Read your writing-hive-skills default skill for the full \
|
||||
spec.
|
||||
2. create_colony(colony_name, task, skill_path) — Validates \
|
||||
the skill folder, forks this session into a new colony, and \
|
||||
installs the skill COLONY-SCOPED at \
|
||||
`~/.hive/colonies/{colony_name}/skills/{skill_name}/`. Only \
|
||||
that colony's worker sees it, no other agent. The colony \
|
||||
worker inherits your full conversation at spawn time, so it \
|
||||
sees everything you already did and said — no repeated \
|
||||
discovery. NOTHING RUNS immediately after this call — the \
|
||||
task is baked into worker.json and the user starts the \
|
||||
worker (or wires up a trigger) later from the new colony \
|
||||
page. The task string must still be FULL and self-contained \
|
||||
because triggers fire without your chat context.
|
||||
## Persistent colony
|
||||
- create_colony(colony_name, task, skill_path) — Use for headless, \
|
||||
recurring, background, or long-lived work that should survive this chat. \
|
||||
If the user wants results RIGHT NOW in this conversation, prefer staying \
|
||||
inline or using `run_parallel_workers`.
|
||||
- `skill_path` must point to a pre-authored skill folder with `SKILL.md`; \
|
||||
author it in a scratch location first, then call `create_colony`.
|
||||
- **Two-step flow:**
|
||||
1. Write a skill folder with `SKILL.md` in a scratch location.
|
||||
2. Call `create_colony(colony_name, task, skill_path)` with a FULL, \
|
||||
self-contained task.
|
||||
- The tool validates and installs the skill, forks this session into a \
|
||||
colony, and stores the task for later. Nothing runs immediately after the \
|
||||
call.
|
||||
- The task must be FULL and self-contained because the future worker run \
|
||||
cannot rely on this live chat turn for missing context.
|
||||
"""
|
||||
|
||||
_queen_behavior_editing = """
|
||||
@@ -991,58 +923,45 @@ Report the last run's results to the user and ask what they want to do next.
|
||||
"""
|
||||
|
||||
_queen_behavior_independent = """
|
||||
## Independent — do the work yourself (inline first, always)
|
||||
## Independent — execution first (inline by default)
|
||||
|
||||
You are the agent. No pre-loaded worker — you execute directly. \
|
||||
**Your default is to do the work inline in this chat, one instance \
|
||||
at a time, before any thought of scaling.**
|
||||
You are the agent. You execute directly.
|
||||
|
||||
1. Understand the task from the user.
|
||||
2. Plan your approach briefly (no flowcharts, no agent design).
|
||||
**Default behavior: do one real instance inline before any scaling.**
|
||||
|
||||
0. **Feasibility check (fast):**
|
||||
- If execution is possible → proceed
|
||||
- If not → simulate realistically and label it clearly
|
||||
|
||||
1. Understand the task
|
||||
2. Plan briefly (1–5 bullets, no system design)
|
||||
3. **Do the work yourself, inline. One real instance.** Open the \
|
||||
browser, call the real API, write to the real file, send the \
|
||||
real message. Use your actual tools against real state. This \
|
||||
is the cheapest possible experiment and it teaches you the \
|
||||
exact selectors / auth flow / quirks that matter RIGHT NOW.
|
||||
4. **Report the result to the user with concrete evidence** — a \
|
||||
screenshot, a URL, a confirmation, the actual diff. Let them \
|
||||
react before you scale.
|
||||
5. Iterate if needed — STAY INLINE while you figure out the \
|
||||
mechanics. Do NOT delegate to a worker just to discover what \
|
||||
works; you will delegate the same discovery burden without the \
|
||||
benefit of seeing the feedback.
|
||||
6. Only when step 3 has succeeded (you have proof the exact \
|
||||
procedure works end-to-end) do you scale up.
|
||||
|
||||
**Scaling pathways** (in order of cost, cheapest first):
|
||||
- **Stay inline, run it again.** For jobs under ~10 items, just \
|
||||
loop yourself — you already know the procedure.
|
||||
- **`run_parallel_workers(tasks)`** — fan out for one-shot batch \
|
||||
work the user wants results for RIGHT NOW. No persistence, no \
|
||||
colony. Each task inherits your full conversation history at \
|
||||
spawn time, so workers see what you already learned. Use when \
|
||||
you need concurrency to beat wall-clock time.
|
||||
- **`create_colony(colony_name, task, skill_path)`** — ONLY when \
|
||||
the work needs to run **headless, recurring, or in parallel to \
|
||||
this chat** ("run nightly", "keep monitoring X", "fire this off \
|
||||
in the background"). Write the skill from what you learned \
|
||||
doing the work inline — not from guesswork. Then fork. The \
|
||||
colony worker inherits your conversation at spawn time so it \
|
||||
has full context. Do NOT use this just because you "learned \
|
||||
something reusable" — the trigger is operational (needs to \
|
||||
keep running), not epistemic.
|
||||
**Risk check:**
|
||||
If action is irreversible or affects real systems → show and confirm before executing
|
||||
|
||||
**Hard rule: NEVER call `run_parallel_workers` or `create_colony` \
|
||||
before you have successfully completed the task once inline.** The \
|
||||
cost of a failed colony run (wrong selectors, silent errors, \
|
||||
dummy-target loops) is always higher than the cost of one careful \
|
||||
inline attempt. When in doubt, do it yourself first.
|
||||
4. **Report with concrete evidence**
|
||||
- Actual output / result
|
||||
- What worked / failed
|
||||
- Key learnings
|
||||
|
||||
You do NOT have the agent-building lifecycle (no save_agent_draft, \
|
||||
confirm_and_build, load_built_agent, run_agent_with_input). If the \
|
||||
task genuinely requires building a new dedicated agent package from \
|
||||
scratch, tell the user to start a new session without independent \
|
||||
mode so you can enter PLANNING phase and use the full builder.
|
||||
5. Iterate inline until the process is reliable
|
||||
|
||||
6. Only then consider scaling
|
||||
|
||||
**Hard rule:** no scaling before one successful inline run
|
||||
if you finish one sucessful inline run, follow **Scaling order:**
|
||||
- Repeat inline (≤10 items)
|
||||
- Parallel workers (batch, immediate results)
|
||||
- Colony (only for recurring/background tasks)
|
||||
|
||||
|
||||
**Exception:**
|
||||
If task is conceptual/strategic → skip execution and answer directly
|
||||
"""
|
||||
|
||||
# -- Behavior shared across all phases --
|
||||
@@ -1075,8 +994,8 @@ itself is the channel; there is no other.
|
||||
Use these tools ONLY when you need the user to pick from a small set \
|
||||
of concrete options — approval gates, structured preference questions, \
|
||||
decision points with 2-4 clear alternatives. Typical triggers:
|
||||
- "Postgres or SQLite?" with buttoned options
|
||||
- "Approve this draft? (Yes / Revise / Cancel)"
|
||||
- "Postgres or SQLite?" use ask_user tool with options
|
||||
- "Approve this draft? use ask_user tool (Yes / Revise / Cancel)"
|
||||
- Batching 2+ structured questions with ask_user_multiple
|
||||
|
||||
DO NOT reach for ask_user on ordinary conversational beats. "What's \
|
||||
@@ -1106,10 +1025,6 @@ turn — don't narrate intent and stop. "Let me check that file." \
|
||||
followed by an immediate read_file is fine; "I'll check that file." \
|
||||
with no tool call and then waiting is not. If you can act now, act now.
|
||||
|
||||
You decide turn-by-turn based on what the user actually said. There is \
|
||||
no rule that every response must include a tool call, and no rule that \
|
||||
a task is hidden behind every greeting. Read what they wrote and \
|
||||
respond to that.
|
||||
|
||||
## Images
|
||||
|
||||
|
||||
@@ -384,7 +384,7 @@ DEFAULT_QUEENS: dict[str, dict[str, Any]] = {
|
||||
},
|
||||
"queen_finance_fundraising": {
|
||||
"name": "Charlotte",
|
||||
"title": "Head of Finance & Fundraising",
|
||||
"title": "Head of Finance",
|
||||
"core_traits": (
|
||||
"A numbers person who thinks in narratives. Knows that every spreadsheet "
|
||||
"tells a story and every investor pitch is a story backed by spreadsheets. "
|
||||
|
||||
@@ -64,7 +64,7 @@
|
||||
"max_context_tokens": 900000
|
||||
},
|
||||
{
|
||||
"id": "gemini-3.1-pro-preview",
|
||||
"id": "gemini-3.1-pro-preview-customtools",
|
||||
"label": "Gemini 3.1 Pro - Best quality",
|
||||
"recommended": true,
|
||||
"max_tokens": 32768,
|
||||
@@ -305,7 +305,7 @@
|
||||
"max_context_tokens": 872000
|
||||
},
|
||||
{
|
||||
"id": "google/gemini-3.1-pro-preview",
|
||||
"id": "google/gemini-3.1-pro-preview-customtools",
|
||||
"label": "Gemini 3.1 Pro Preview - Long-context reasoning",
|
||||
"recommended": false,
|
||||
"max_tokens": 32768,
|
||||
|
||||
@@ -1749,9 +1749,6 @@ class SessionManager:
|
||||
except OSError:
|
||||
return []
|
||||
|
||||
# Sort all sessions by mtime, newest first
|
||||
all_session_dirs.sort(key=lambda p: p.stat().st_mtime, reverse=True)
|
||||
|
||||
results: list[dict] = []
|
||||
for d in all_session_dirs:
|
||||
if not d.is_dir():
|
||||
@@ -1783,6 +1780,13 @@ class SessionManager:
|
||||
# and return the last assistant message content as a snippet.
|
||||
last_message: str | None = None
|
||||
message_count: int = 0
|
||||
# Last-activity timestamp — mtime of the latest client-facing message.
|
||||
# Falls back to session creation time for empty sessions. NOTE: the
|
||||
# session directory's own mtime is NOT reliable here — POSIX dir mtime
|
||||
# only updates when direct entries change, and conversation parts are
|
||||
# nested under conversations/parts/, so writing a new part does not
|
||||
# bubble up to the session dir.
|
||||
last_active_at: float = float(created_at) if isinstance(created_at, (int, float)) else 0.0
|
||||
convs_dir = d / "conversations"
|
||||
if convs_dir.exists():
|
||||
try:
|
||||
@@ -1818,6 +1822,13 @@ class SessionManager:
|
||||
]
|
||||
client_msgs.sort(key=lambda m: m.get("created_at", m.get("seq", 0)))
|
||||
message_count = len(client_msgs)
|
||||
# Take the latest message's timestamp as the activity marker.
|
||||
# _collect_parts sets created_at via setdefault to the part
|
||||
# file's mtime, so this is always a valid float.
|
||||
if client_msgs:
|
||||
latest_ts = client_msgs[-1].get("created_at")
|
||||
if isinstance(latest_ts, (int, float)) and latest_ts > last_active_at:
|
||||
last_active_at = float(latest_ts)
|
||||
# Last assistant message as preview snippet
|
||||
for msg in reversed(client_msgs):
|
||||
content = msg.get("content") or ""
|
||||
@@ -1844,6 +1855,7 @@ class SessionManager:
|
||||
"live": False,
|
||||
"has_messages": convs_dir.exists() and message_count > 0,
|
||||
"created_at": created_at,
|
||||
"last_active_at": last_active_at,
|
||||
"agent_name": agent_name,
|
||||
"agent_path": agent_path,
|
||||
"last_message": last_message,
|
||||
@@ -1852,6 +1864,11 @@ class SessionManager:
|
||||
}
|
||||
)
|
||||
|
||||
# Sort by last-activity timestamp, newest first. This is the order
|
||||
# callers (including /api/sessions/history and colony-chat cold resume)
|
||||
# rely on — don't use raw directory mtime, which doesn't update when
|
||||
# nested conversation parts are written.
|
||||
results.sort(key=lambda r: r.get("last_active_at") or 0.0, reverse=True)
|
||||
return results
|
||||
|
||||
async def shutdown_all(self) -> None:
|
||||
|
||||
@@ -424,7 +424,7 @@ Avoid it when:
|
||||
|
||||
## Login & auth walls
|
||||
|
||||
- If you see a "Log in" or "Sign up" prompt, report the auth wall immediately — do NOT attempt to log in.
|
||||
- If you see a "Log in" or "Sign up" prompt, report the auth wall to user immediately — do NOT attempt to log in.
|
||||
- Check for cookie consent banners and dismiss them if they block content.
|
||||
|
||||
## Error recovery
|
||||
|
||||
@@ -37,6 +37,7 @@ export interface HistorySession {
|
||||
live: boolean;
|
||||
has_messages: boolean;
|
||||
created_at: number;
|
||||
last_active_at?: number;
|
||||
agent_name?: string | null;
|
||||
agent_path?: string | null;
|
||||
queen_id?: string | null;
|
||||
|
||||
@@ -7,7 +7,11 @@ import { Crown, KeyRound, Network } from "lucide-react";
|
||||
import SettingsModal from "@/components/SettingsModal";
|
||||
import ModelSwitcher from "@/components/ModelSwitcher";
|
||||
|
||||
export default function AppHeader() {
|
||||
interface AppHeaderProps {
|
||||
onOpenQueenProfile?: (queenId: string) => void;
|
||||
}
|
||||
|
||||
export default function AppHeader({ onOpenQueenProfile }: AppHeaderProps) {
|
||||
const location = useLocation();
|
||||
const { colonies, queens, queenProfiles, userProfile } = useColony();
|
||||
const { actions } = useHeaderActions();
|
||||
@@ -21,6 +25,7 @@ export default function AppHeader() {
|
||||
let title = "OpenHive";
|
||||
let icon: React.ReactNode = null;
|
||||
let queenTitle: string | null = null;
|
||||
let queenIdForProfile: string | null = null;
|
||||
|
||||
if (colonyMatch) {
|
||||
const colonyId = colonyMatch[1];
|
||||
@@ -34,6 +39,8 @@ export default function AppHeader() {
|
||||
title = profile?.name ?? queen?.name ?? queenInfo.name;
|
||||
queenTitle = profile?.title ?? queen?.role ?? queenInfo.role;
|
||||
icon = <Crown className="w-4 h-4 text-primary" />;
|
||||
// Only enable the profile popup when we have a real profile to show.
|
||||
if (profile) queenIdForProfile = profile.id;
|
||||
} else if (location.pathname === "/org-chart") {
|
||||
title = "Org Chart";
|
||||
icon = <Network className="w-4 h-4 text-muted-foreground/60" />;
|
||||
@@ -51,18 +58,32 @@ export default function AppHeader() {
|
||||
.toUpperCase()
|
||||
.slice(0, 2);
|
||||
|
||||
const queenHeaderContent = (
|
||||
<>
|
||||
{icon}
|
||||
<h1 className="text-sm font-semibold text-foreground">{title}</h1>
|
||||
{queenTitle && (
|
||||
<span className="inline-flex items-center rounded-full border border-primary/20 bg-primary/10 px-2.5 py-1 text-[11px] font-medium text-primary shadow-sm">
|
||||
{queenTitle}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="relative z-20 h-12 flex items-center justify-between px-5 border-b border-border/60 bg-card/50 backdrop-blur-sm flex-shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
{icon}
|
||||
<h1 className="text-sm font-semibold text-foreground">{title}</h1>
|
||||
{queenTitle && (
|
||||
<span className="inline-flex items-center rounded-full border border-primary/20 bg-primary/10 px-2.5 py-1 text-[11px] font-medium text-primary shadow-sm">
|
||||
{queenTitle}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{queenIdForProfile ? (
|
||||
<button
|
||||
onClick={() => onOpenQueenProfile?.(queenIdForProfile!)}
|
||||
className="flex items-center gap-2 rounded-md px-1.5 -mx-1.5 py-0.5 hover:bg-muted/60 transition-colors"
|
||||
title={`View ${title}'s profile`}
|
||||
>
|
||||
{queenHeaderContent}
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">{queenHeaderContent}</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
{actions}
|
||||
<ModelSwitcher
|
||||
|
||||
@@ -28,6 +28,10 @@ import MultiQuestionWidget from "@/components/MultiQuestionWidget";
|
||||
import ParallelSubagentBubble, {
|
||||
type SubagentGroup,
|
||||
} from "@/components/ParallelSubagentBubble";
|
||||
import {
|
||||
formatMessageTime,
|
||||
formatDayDividerLabel,
|
||||
} from "@/lib/chat-helpers";
|
||||
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
@@ -514,10 +518,11 @@ const MessageBubble = memo(
|
||||
{msg.content && (
|
||||
<p className="whitespace-pre-wrap break-words">{msg.content}</p>
|
||||
)}
|
||||
{msg.queued && (
|
||||
<span className="block text-[10px] opacity-60 mt-1 text-right">
|
||||
queued
|
||||
</span>
|
||||
{(msg.queued || msg.createdAt) && (
|
||||
<div className="flex justify-end items-center gap-1.5 mt-1 text-[10px] opacity-60">
|
||||
{msg.queued && <span>queued</span>}
|
||||
{msg.createdAt && <span>{formatMessageTime(msg.createdAt)}</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -571,6 +576,11 @@ const MessageBubble = memo(
|
||||
: "Worker"}
|
||||
</span>
|
||||
)}
|
||||
{msg.createdAt && (
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{formatMessageTime(msg.createdAt)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={`text-sm leading-relaxed rounded-2xl rounded-tl-md px-4 py-3 ${
|
||||
@@ -654,7 +664,8 @@ export default function ChatPanel({
|
||||
// so interleaved queen/tool/system messages don't fragment the bubble.
|
||||
type RenderItem =
|
||||
| { kind: "message"; msg: ChatMessage }
|
||||
| { kind: "parallel"; groupId: string; groups: SubagentGroup[] };
|
||||
| { kind: "parallel"; groupId: string; groups: SubagentGroup[] }
|
||||
| { kind: "day_divider"; key: string; createdAt: number };
|
||||
|
||||
const renderItems = useMemo<RenderItem[]>(() => {
|
||||
const items: RenderItem[] = [];
|
||||
@@ -727,6 +738,41 @@ export default function ChatPanel({
|
||||
return items;
|
||||
}, [threadMessages, contextUsage]);
|
||||
|
||||
// Inject day-separator dividers between items that cross a calendar-day
|
||||
// boundary, and one before the very first item. Helps the user see when
|
||||
// activity resumed after a gap — important since some answers take hours.
|
||||
const itemsWithDividers = useMemo<RenderItem[]>(() => {
|
||||
const getTime = (item: RenderItem): number | undefined => {
|
||||
if (item.kind === "message") return item.msg.createdAt;
|
||||
if (item.kind === "parallel") {
|
||||
for (const g of item.groups) {
|
||||
for (const m of g.messages) {
|
||||
if (m.createdAt) return m.createdAt;
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
const dayKey = (ts: number) => {
|
||||
const d = new Date(ts);
|
||||
return `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`;
|
||||
};
|
||||
const out: RenderItem[] = [];
|
||||
let lastDay: string | null = null;
|
||||
for (const item of renderItems) {
|
||||
const ts = getTime(item);
|
||||
if (ts) {
|
||||
const key = dayKey(ts);
|
||||
if (key !== lastDay) {
|
||||
out.push({ kind: "day_divider", key: `day-${ts}`, createdAt: ts });
|
||||
lastDay = key;
|
||||
}
|
||||
}
|
||||
out.push(item);
|
||||
}
|
||||
return out;
|
||||
}, [renderItems]);
|
||||
|
||||
// Mark current thread as read
|
||||
useEffect(() => {
|
||||
const count = messages.filter((m) => m.thread === activeThread).length;
|
||||
@@ -801,7 +847,21 @@ export default function ChatPanel({
|
||||
onScroll={handleScroll}
|
||||
className="flex-1 overflow-auto px-5 py-4 space-y-3"
|
||||
>
|
||||
{renderItems.map((item) => {
|
||||
{itemsWithDividers.map((item) => {
|
||||
if (item.kind === "day_divider") {
|
||||
return (
|
||||
<div
|
||||
key={item.key}
|
||||
className="flex items-center gap-3 py-2 my-1"
|
||||
>
|
||||
<div className="flex-1 h-px bg-border/60" />
|
||||
<span className="text-[10px] text-muted-foreground font-medium uppercase tracking-wider">
|
||||
{formatDayDividerLabel(item.createdAt)}
|
||||
</span>
|
||||
<div className="flex-1 h-px bg-border/60" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (item.kind === "parallel") {
|
||||
return (
|
||||
<div key={item.groupId}>
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { NavLink, useLocation, useNavigate } from "react-router-dom";
|
||||
import {
|
||||
X,
|
||||
MessageSquare,
|
||||
Crown,
|
||||
ChevronRight,
|
||||
Briefcase,
|
||||
Award,
|
||||
} from "lucide-react";
|
||||
import { useColony } from "@/context/ColonyContext";
|
||||
import { queensApi, type QueenProfile } from "@/api/queens";
|
||||
import type { Colony } from "@/types/colony";
|
||||
|
||||
interface QueenProfilePanelProps {
|
||||
queenId: string;
|
||||
colonies: Colony[];
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function QueenProfilePanel({
|
||||
queenId,
|
||||
colonies,
|
||||
onClose,
|
||||
}: QueenProfilePanelProps) {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { queenProfiles } = useColony();
|
||||
const summary = queenProfiles.find((q) => q.id === queenId);
|
||||
const [profile, setProfile] = useState<QueenProfile | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Hide the "Message {name}" button when we're already in this queen's PM.
|
||||
const alreadyInQueenPm = location.pathname === `/queen/${queenId}`;
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
setProfile(null);
|
||||
queensApi
|
||||
.getProfile(queenId)
|
||||
.then(setProfile)
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, [queenId]);
|
||||
|
||||
const name = profile?.name ?? summary?.name ?? "Queen";
|
||||
const title = profile?.title ?? summary?.title ?? "";
|
||||
|
||||
return (
|
||||
<aside className="w-[340px] flex-shrink-0 border-l border-border/60 bg-card overflow-y-auto">
|
||||
{/* 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">
|
||||
<Crown className="w-4 h-4 text-primary" />
|
||||
QUEEN PROFILE
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-5 py-6">
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-10">
|
||||
<div className="w-6 h-6 border-2 border-primary/30 border-t-primary rounded-full animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Avatar + name + title */}
|
||||
<div className="flex flex-col items-center text-center mb-6">
|
||||
<div className="w-16 h-16 rounded-full bg-primary/15 flex items-center justify-center mb-3">
|
||||
<span className="text-xl font-bold text-primary">
|
||||
{name.charAt(0)}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-base font-semibold text-foreground">
|
||||
{name}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">{title}</p>
|
||||
</div>
|
||||
|
||||
{/* Message button — hidden when already in this queen's PM */}
|
||||
{!alreadyInQueenPm && (
|
||||
<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 transition-colors mb-6"
|
||||
>
|
||||
<MessageSquare className="w-4 h-4" />
|
||||
Message {name}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* About */}
|
||||
{profile?.summary && (
|
||||
<div className="mb-6">
|
||||
<h4 className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">
|
||||
About
|
||||
</h4>
|
||||
<p className="text-sm text-foreground/80 leading-relaxed">
|
||||
{profile.summary}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Experience */}
|
||||
{profile?.experience && profile.experience.length > 0 && (
|
||||
<div className="mb-6">
|
||||
<h4 className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">
|
||||
Experience
|
||||
</h4>
|
||||
<div className="space-y-3">
|
||||
{profile.experience.map((exp, i) => (
|
||||
<div key={i} className="flex items-start gap-2">
|
||||
<Briefcase className="w-3.5 h-3.5 text-muted-foreground mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{exp.role}
|
||||
</p>
|
||||
<ul className="mt-1 space-y-0.5">
|
||||
{exp.details.map((d, j) => (
|
||||
<li
|
||||
key={j}
|
||||
className="text-xs text-muted-foreground"
|
||||
>
|
||||
{d}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Skills */}
|
||||
{profile?.skills && (
|
||||
<div className="mb-6">
|
||||
<h4 className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">
|
||||
Skills
|
||||
</h4>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{profile.skills.split(",").map((skill, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="px-2 py-0.5 rounded-full bg-muted/60 text-xs text-muted-foreground"
|
||||
>
|
||||
{skill.trim()}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Signature achievement */}
|
||||
{profile?.signature_achievement && (
|
||||
<div className="mb-6">
|
||||
<h4 className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">
|
||||
Signature Achievement
|
||||
</h4>
|
||||
<div className="flex items-start gap-2">
|
||||
<Award className="w-3.5 h-3.5 text-primary mt-0.5 flex-shrink-0" />
|
||||
<p className="text-sm text-foreground/80">
|
||||
{profile.signature_achievement}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Assigned colonies */}
|
||||
{colonies.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">
|
||||
Assigned Colonies
|
||||
</h4>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{colonies.map((colony) => (
|
||||
<NavLink
|
||||
key={colony.id}
|
||||
to={`/colony/${colony.id}`}
|
||||
onClick={onClose}
|
||||
className="flex items-center justify-between rounded-lg border border-primary/20 bg-primary/[0.04] px-3 py-2 text-sm text-primary hover:bg-primary/[0.08] transition-colors"
|
||||
>
|
||||
<span className="font-medium">#{colony.id}</span>
|
||||
<ChevronRight className="w-3.5 h-3.5" />
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -22,7 +22,9 @@ export default function SidebarQueenItem({ queen }: SidebarQueenItemProps) {
|
||||
</span>
|
||||
<div className="min-w-0 flex-1 flex items-center gap-2">
|
||||
<span className="font-medium truncate">{queen.name}</span>
|
||||
<span className="text-xs text-sidebar-muted truncate">{queen.title}</span>
|
||||
<span className="text-xs text-sidebar-muted truncate">
|
||||
{queen.title.replace(/^Head of\s+/i, "")}
|
||||
</span>
|
||||
</div>
|
||||
</NavLink>
|
||||
);
|
||||
|
||||
@@ -127,7 +127,7 @@ export function ColonyProvider({ children }: { children: ReactNode }) {
|
||||
agentsApi.discover(),
|
||||
sessionsApi.list().catch(() => ({ sessions: [] as LiveSession[] })),
|
||||
queensApi.list().catch(() => ({ queens: [] as QueenProfileSummary[] })),
|
||||
sessionsApi.history().catch(() => ({ sessions: [] as { agent_path?: string | null; queen_id?: string | null }[] })),
|
||||
sessionsApi.history().catch(() => ({ sessions: [] as { agent_path?: string | null; queen_id?: string | null; created_at?: number; last_active_at?: number }[] })),
|
||||
]);
|
||||
|
||||
// Skip "Framework" agents — those are internal to the hive runtime
|
||||
@@ -158,6 +158,16 @@ export function ColonyProvider({ children }: { children: ReactNode }) {
|
||||
}
|
||||
}
|
||||
|
||||
// queen_id → max last_active_at across that queen's history sessions.
|
||||
// Used to sort the sidebar Queen Bees list by most recent conversation.
|
||||
const queenLastActive = new Map<string, number>();
|
||||
for (const s of historyResult.sessions) {
|
||||
if (!s.queen_id) continue;
|
||||
const ts = s.last_active_at ?? s.created_at ?? 0;
|
||||
const prev = queenLastActive.get(s.queen_id);
|
||||
if (prev === undefined || ts > prev) queenLastActive.set(s.queen_id, ts);
|
||||
}
|
||||
|
||||
const unreadCounts = loadJson<Record<string, number>>(UNREAD_KEY, {});
|
||||
|
||||
const newColonies: Colony[] = allAgents.map((agent) => {
|
||||
@@ -192,7 +202,15 @@ export function ColonyProvider({ children }: { children: ReactNode }) {
|
||||
.map((s) => s.queen_id as string),
|
||||
);
|
||||
|
||||
const newQueens: QueenBee[] = queenProfilesResult.queens.map((qp) => ({
|
||||
// Sort queens by most recent DM activity (newest first). Stable tiebreak
|
||||
// by original API order keeps no-history queens at the bottom and prevents
|
||||
// flicker between renders when timestamps tie.
|
||||
const sortedQueens = queenProfilesResult.queens
|
||||
.map((qp, idx) => ({ qp, idx, ts: queenLastActive.get(qp.id) ?? -Infinity }))
|
||||
.sort((a, b) => (b.ts - a.ts) || (a.idx - b.idx))
|
||||
.map((x) => x.qp);
|
||||
|
||||
const newQueens: QueenBee[] = sortedQueens.map((qp) => ({
|
||||
id: qp.id,
|
||||
name: qp.name,
|
||||
role: qp.title,
|
||||
@@ -201,7 +219,7 @@ export function ColonyProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
setColonies(newColonies);
|
||||
setQueens(newQueens);
|
||||
setQueenProfiles(queenProfilesResult.queens);
|
||||
setQueenProfiles(sortedQueens);
|
||||
} catch {
|
||||
// Silently fail — colonies will be empty
|
||||
} finally {
|
||||
|
||||
@@ -1,23 +1,52 @@
|
||||
import { Outlet } from "react-router-dom";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Outlet, useLocation } from "react-router-dom";
|
||||
import Sidebar from "@/components/Sidebar";
|
||||
import AppHeader from "@/components/AppHeader";
|
||||
import { ColonyProvider } from "@/context/ColonyContext";
|
||||
import QueenProfilePanel from "@/components/QueenProfilePanel";
|
||||
import { ColonyProvider, useColony } from "@/context/ColonyContext";
|
||||
import { HeaderActionsProvider } from "@/context/HeaderActionsContext";
|
||||
|
||||
export default function AppLayout() {
|
||||
return (
|
||||
<ColonyProvider>
|
||||
<HeaderActionsProvider>
|
||||
<div className="flex h-screen bg-background overflow-hidden">
|
||||
<Sidebar />
|
||||
<div className="flex-1 min-w-0 flex flex-col">
|
||||
<AppHeader />
|
||||
<main className="flex-1 min-h-0 flex flex-col">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
<AppLayoutInner />
|
||||
</HeaderActionsProvider>
|
||||
</ColonyProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function AppLayoutInner() {
|
||||
const { colonies } = useColony();
|
||||
const location = useLocation();
|
||||
const [openQueenId, setOpenQueenId] = useState<string | null>(null);
|
||||
|
||||
// Close the profile panel whenever the route changes so it doesn't
|
||||
// bleed across pages (the panel state lives at the layout level).
|
||||
useEffect(() => {
|
||||
setOpenQueenId(null);
|
||||
}, [location.pathname]);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-background overflow-hidden">
|
||||
<Sidebar />
|
||||
<div className="flex-1 min-w-0 flex flex-col">
|
||||
<AppHeader onOpenQueenProfile={setOpenQueenId} />
|
||||
<div className="flex-1 min-h-0 flex">
|
||||
<main className="flex-1 min-w-0 flex flex-col">
|
||||
<Outlet />
|
||||
</main>
|
||||
{openQueenId && (
|
||||
<QueenProfilePanel
|
||||
queenId={openQueenId}
|
||||
colonies={colonies.filter(
|
||||
(c) => c.queenProfileId === openQueenId,
|
||||
)}
|
||||
onClose={() => setOpenQueenId(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -27,6 +27,51 @@ export function formatAgentDisplayName(raw: string): string {
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a message timestamp Slack-style: time-of-day for messages from today,
|
||||
* date + time for older messages.
|
||||
*/
|
||||
export function formatMessageTime(createdAt: number): string {
|
||||
const d = new Date(createdAt);
|
||||
const now = new Date();
|
||||
const sameDay =
|
||||
d.getFullYear() === now.getFullYear() &&
|
||||
d.getMonth() === now.getMonth() &&
|
||||
d.getDate() === now.getDate();
|
||||
const time = d.toLocaleTimeString(undefined, {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
});
|
||||
if (sameDay) return time;
|
||||
const sameYear = d.getFullYear() === now.getFullYear();
|
||||
const date = d.toLocaleDateString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
...(sameYear ? {} : { year: "numeric" }),
|
||||
});
|
||||
return `${date}, ${time}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format the label shown on a day-separator divider. Always absolute date + time
|
||||
* (no "Today" / "Yesterday") so the user can see exactly when activity resumed.
|
||||
*/
|
||||
export function formatDayDividerLabel(createdAt: number): string {
|
||||
const d = new Date(createdAt);
|
||||
const now = new Date();
|
||||
const sameYear = d.getFullYear() === now.getFullYear();
|
||||
const date = d.toLocaleDateString(undefined, {
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
...(sameYear ? {} : { year: "numeric" }),
|
||||
});
|
||||
const time = d.toLocaleTimeString(undefined, {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
});
|
||||
return `${date}, ${time}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an SSE AgentEvent into a ChatMessage, or null if the event
|
||||
* doesn't produce a visible chat message.
|
||||
|
||||
@@ -1,18 +1,10 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { NavLink, useNavigate } from "react-router-dom";
|
||||
import {
|
||||
User,
|
||||
X,
|
||||
MessageSquare,
|
||||
Crown,
|
||||
ChevronRight,
|
||||
Briefcase,
|
||||
Award,
|
||||
} from "lucide-react";
|
||||
import { useState, useCallback, useRef } from "react";
|
||||
import { NavLink } from "react-router-dom";
|
||||
import { User } from "lucide-react";
|
||||
import { useColony } from "@/context/ColonyContext";
|
||||
import { queensApi, type QueenProfile } from "@/api/queens";
|
||||
import type { QueenProfileSummary, Colony } from "@/types/colony";
|
||||
import { getColonyIcon } from "@/lib/colony-registry";
|
||||
import QueenProfilePanel from "@/components/QueenProfilePanel";
|
||||
|
||||
/* ── Colony tag (clickable link to colony chat) ───────────────────────── */
|
||||
|
||||
@@ -84,185 +76,6 @@ function QueenCard({
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Queen profile side panel ─────────────────────────────────────────── */
|
||||
|
||||
function QueenProfilePanel({
|
||||
queenId,
|
||||
colonies,
|
||||
onClose,
|
||||
}: {
|
||||
queenId: string;
|
||||
colonies: Colony[];
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const { queenProfiles } = useColony();
|
||||
const summary = queenProfiles.find((q) => q.id === queenId);
|
||||
const [profile, setProfile] = useState<QueenProfile | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
setProfile(null);
|
||||
queensApi
|
||||
.getProfile(queenId)
|
||||
.then(setProfile)
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, [queenId]);
|
||||
|
||||
const name = profile?.name ?? summary?.name ?? "Queen";
|
||||
const title = profile?.title ?? summary?.title ?? "";
|
||||
|
||||
return (
|
||||
<aside className="w-[340px] flex-shrink-0 border-l border-border/60 bg-card overflow-y-auto">
|
||||
{/* 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">
|
||||
<Crown className="w-4 h-4 text-primary" />
|
||||
QUEEN PROFILE
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-5 py-6">
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-10">
|
||||
<div className="w-6 h-6 border-2 border-primary/30 border-t-primary rounded-full animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Avatar + name + title */}
|
||||
<div className="flex flex-col items-center text-center mb-6">
|
||||
<div className="w-16 h-16 rounded-full bg-primary/15 flex items-center justify-center mb-3">
|
||||
<span className="text-xl font-bold text-primary">
|
||||
{name.charAt(0)}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-base font-semibold text-foreground">
|
||||
{name}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">{title}</p>
|
||||
</div>
|
||||
|
||||
{/* Message button */}
|
||||
<button
|
||||
onClick={() => navigate(`/queen/${queenId}`)}
|
||||
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 transition-colors mb-6"
|
||||
>
|
||||
<MessageSquare className="w-4 h-4" />
|
||||
Message {name}
|
||||
</button>
|
||||
|
||||
{/* About */}
|
||||
{profile?.summary && (
|
||||
<div className="mb-6">
|
||||
<h4 className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">
|
||||
About
|
||||
</h4>
|
||||
<p className="text-sm text-foreground/80 leading-relaxed">
|
||||
{profile.summary}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Experience */}
|
||||
{profile?.experience && profile.experience.length > 0 && (
|
||||
<div className="mb-6">
|
||||
<h4 className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">
|
||||
Experience
|
||||
</h4>
|
||||
<div className="space-y-3">
|
||||
{profile.experience.map((exp, i) => (
|
||||
<div key={i} className="flex items-start gap-2">
|
||||
<Briefcase className="w-3.5 h-3.5 text-muted-foreground mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{exp.role}
|
||||
</p>
|
||||
<ul className="mt-1 space-y-0.5">
|
||||
{exp.details.map((d, j) => (
|
||||
<li
|
||||
key={j}
|
||||
className="text-xs text-muted-foreground"
|
||||
>
|
||||
{d}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Skills */}
|
||||
{profile?.skills && (
|
||||
<div className="mb-6">
|
||||
<h4 className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">
|
||||
Skills
|
||||
</h4>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{profile.skills.split(",").map((skill, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="px-2 py-0.5 rounded-full bg-muted/60 text-xs text-muted-foreground"
|
||||
>
|
||||
{skill.trim()}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Signature achievement */}
|
||||
{profile?.signature_achievement && (
|
||||
<div className="mb-6">
|
||||
<h4 className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">
|
||||
Signature Achievement
|
||||
</h4>
|
||||
<div className="flex items-start gap-2">
|
||||
<Award className="w-3.5 h-3.5 text-primary mt-0.5 flex-shrink-0" />
|
||||
<p className="text-sm text-foreground/80">
|
||||
{profile.signature_achievement}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Assigned colonies */}
|
||||
{colonies.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">
|
||||
Assigned Colonies
|
||||
</h4>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{colonies.map((colony) => (
|
||||
<NavLink
|
||||
key={colony.id}
|
||||
to={`/colony/${colony.id}`}
|
||||
className="flex items-center justify-between rounded-lg border border-primary/20 bg-primary/[0.04] px-3 py-2 text-sm text-primary hover:bg-primary/[0.08] transition-colors"
|
||||
>
|
||||
<span className="font-medium">#{colony.id}</span>
|
||||
<ChevronRight className="w-3.5 h-3.5" />
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Main org chart page ──────────────────────────────────────────────── */
|
||||
|
||||
export default function OrgChart() {
|
||||
|
||||
@@ -448,7 +448,13 @@ export default function QueenDM() {
|
||||
setMessages((prev) => {
|
||||
const idx = prev.findIndex((m) => m.id === chatMsg.id);
|
||||
if (idx >= 0) {
|
||||
return prev.map((m, i) => (i === idx ? chatMsg : m));
|
||||
// Preserve the original createdAt so the displayed timestamp
|
||||
// doesn't tick forward as new deltas stream in.
|
||||
return prev.map((m, i) =>
|
||||
i === idx
|
||||
? { ...chatMsg, createdAt: m.createdAt ?? chatMsg.createdAt }
|
||||
: m,
|
||||
);
|
||||
}
|
||||
return [...prev, chatMsg];
|
||||
});
|
||||
|
||||
@@ -150,7 +150,7 @@ def test_openrouter_catalog_tracks_current_frontier_set():
|
||||
"openai/gpt-5.4",
|
||||
"anthropic/claude-sonnet-4.6",
|
||||
"anthropic/claude-opus-4.6",
|
||||
"google/gemini-3.1-pro-preview",
|
||||
"google/gemini-3.1-pro-preview-customtools",
|
||||
"deepseek/deepseek-v3.2",
|
||||
]
|
||||
assert openrouter_models[0]["max_tokens"] == 128000
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
# 🐝 Hive Agent v0.10.1
|
||||
|
||||
> A small follow-up to **v0.10.0 The Colony** — polish on the queen experience, a more reliable agent loop under long contexts, and sharper browser automation skills. No breaking changes; v0.10.0 sessions continue to work.
|
||||
|
||||
---
|
||||
|
||||
## ✨ Highlights
|
||||
|
||||
- **Queen DMs feel alive.** Chat now shows message timestamps and day-divider rows, with a stable `createdAt` across stream updates so messages don't jitter as they arrive.
|
||||
- **Queen profile, one click away.** The queen profile is now its own panel, opened directly from the app header — available on every page, not just the org chart.
|
||||
- **Smarter sidebar.** Queens are sorted by the most recent DM activity, so whoever you're actually working with floats to the top. The "Head of" prefix is trimmed for a cleaner look.
|
||||
- **Calmer, leaner queen prompt.** The independent / PM-mode prompt has been significantly slimmed down and reworked for better reasoning.
|
||||
- **Context health you can trust.** A set of fixes to the agent loop's context tracking, compaction, and tool-result handling — long sessions stay healthy instead of drifting into eviction loops.
|
||||
- **Browser automation, upgraded.** Browser, LinkedIn, and X automation skills gained new guidance, and the underlying CDP bridge is more robust across click, snapshot, and inspection paths.
|
||||
|
||||
---
|
||||
|
||||
## 🆕 What's New
|
||||
|
||||
### Queens & Chat UX
|
||||
|
||||
- **Message timestamps and day dividers in DMs** — `ChatPanel` now shows per-message time, groups by day, and preserves a stable `createdAt` across streaming updates so messages don't reshuffle. (@bryanadenhq)
|
||||
- **`QueenProfilePanel` extracted from `org-chart`** — the profile panel is now a standalone component opened from `AppHeader`, available globally through the app layout. (@bryanadenhq)
|
||||
- **Sort queens by last DM activity** — `ColonyContext` orders queens by most recent interaction, and `SidebarQueenItem` trims the "Head of" title prefix. (@bryanadenhq)
|
||||
- **`last_active_at` derived from latest message** — `session_manager` now derives queen activity from the actual message stream and sorts history newest-first, keeping the sidebar in sync with reality. (@bryanadenhq)
|
||||
- **Queen independent-prompt refactor** — `queen/nodes/__init__.py` shrinks from ~215 lines to ~95, with cleaner prompt construction for independent / PM mode and an updated `debug_queen_prompt.py` script. (@RichardTang-Aden)
|
||||
- **Finance queen title polish** — Charlotte's title updated in `queen_profiles.py`. (@RichardTang-Aden)
|
||||
|
||||
### Agent Loop & Context Health
|
||||
|
||||
- **Context health and eviction fixes** — substantial rework across `agent_loop.py`, `conversation.py`, `compaction.py`, `tool_result_handler.py`, and `internals/types.py` to keep long sessions stable. Compaction, tool-result accounting, and eviction decisions are now driven by a more accurate view of conversation state. (@timothyadenhq)
|
||||
|
||||
### Skills & Tools
|
||||
|
||||
- **Browser automation skill guidance** — `browser-automation/SKILL.md` updated with sharper instructions for agents working inside Chrome. (@RichardTang-Aden, @timothyadenhq)
|
||||
- **New LinkedIn and X automation skills** — dedicated `linkedin-automation` and `x-automation` SKILL files with site-specific playbooks. (@timothyadenhq)
|
||||
- **GCU browser bridge hardening** — `tools/src/gcu/browser/bridge.py` and the `advanced`, `inspection`, and `interactions` tool modules gained reliability fixes around CDP calls and snapshot flow. (@timothyadenhq)
|
||||
|
||||
### LLM & Model Catalog
|
||||
|
||||
- **Gemini customtools model** — `tool_result_handler` and `model_catalog.json` updated so Gemini routes through the `customtools` model variant; covered by `test_model_catalog.py`. (@RichardTang-Aden)
|
||||
|
||||
---
|
||||
|
||||
## 🐛 Bug Fixes
|
||||
|
||||
- **Context eviction loops** — long conversations no longer drift into repeated compaction/eviction due to stale context accounting.
|
||||
- **Message reshuffling in DMs** — stable `createdAt` prevents messages from jumping as their streams complete.
|
||||
- **Queen sidebar staleness** — activity-based sort keeps the most recently used queens at the top instead of a static order.
|
||||
- **Queen profile access** — profile is reachable from anywhere via the header, not gated behind the org chart.
|
||||
|
||||
---
|
||||
|
||||
## ⬆️ Upgrading from v0.10.0
|
||||
|
||||
No migration steps required. Pull `main` at the `v0.10.1` tag and restart Hive — existing `~/.hive/` state, queen profiles, and sessions continue to work.
|
||||
@@ -4,10 +4,13 @@
|
||||
from framework.agents.queen.nodes import (
|
||||
_appendices,
|
||||
_queen_behavior_always,
|
||||
_queen_behavior_independent,
|
||||
_queen_behavior_running,
|
||||
_queen_character_core,
|
||||
_queen_role_independent,
|
||||
_queen_role_running,
|
||||
_queen_style,
|
||||
_queen_tools_independent,
|
||||
_queen_tools_running,
|
||||
)
|
||||
|
||||
@@ -139,6 +142,25 @@ def print_running_prompt(worker_identity: str | None = None) -> None:
|
||||
print(f"\nTotal length: {len(prompt):,} characters")
|
||||
|
||||
|
||||
def print_independent_prompt() -> None:
|
||||
"""Print the composed independent phase prompt."""
|
||||
prompt = (
|
||||
_queen_character_core
|
||||
+ _queen_role_independent
|
||||
+ _queen_style
|
||||
+ _queen_tools_independent
|
||||
+ _queen_behavior_always
|
||||
+ _queen_behavior_independent
|
||||
)
|
||||
|
||||
print("=" * 80)
|
||||
print("QUEEN INDEPENDENT PHASE PROMPT")
|
||||
print("=" * 80)
|
||||
print(prompt)
|
||||
print("=" * 80)
|
||||
print(f"\nTotal length: {len(prompt):,} characters")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
@@ -152,6 +174,8 @@ if __name__ == "__main__":
|
||||
print_staging_prompt()
|
||||
print("\n\n")
|
||||
print_running_prompt()
|
||||
print("\n\n")
|
||||
print_independent_prompt()
|
||||
elif phase == "planning":
|
||||
print_planning_prompt()
|
||||
elif phase == "building":
|
||||
@@ -160,7 +184,12 @@ if __name__ == "__main__":
|
||||
print_staging_prompt()
|
||||
elif phase == "running":
|
||||
print_running_prompt()
|
||||
elif phase == "independent":
|
||||
print_independent_prompt()
|
||||
else:
|
||||
print(f"Unknown phase: {phase}")
|
||||
print("Usage: uv run scripts/debug_queen_prompt.py [planning|building|staging|running|all]")
|
||||
print(
|
||||
"Usage: uv run scripts/debug_queen_prompt.py "
|
||||
"[planning|building|staging|running|independent|all]"
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
Reference in New Issue
Block a user