session management and ability to converse from where the chat was left off, fix v1

This commit is contained in:
levxn
2026-03-04 11:40:44 +05:30
parent 7c7b60a5e9
commit 8988c1e760
7 changed files with 996 additions and 112 deletions
+40 -2
View File
@@ -122,6 +122,9 @@ async def handle_create_session(request: web.Request) -> web.Response:
session_id = body.get("session_id")
model = body.get("model")
initial_prompt = body.get("initial_prompt")
# When set, the queen writes conversations to this existing session's directory
# so the full history accumulates in one place across server restarts.
queen_resume_from = body.get("queen_resume_from")
if agent_path:
try:
@@ -137,6 +140,7 @@ async def handle_create_session(request: web.Request) -> web.Response:
agent_id=agent_id,
model=model,
initial_prompt=initial_prompt,
queen_resume_from=queen_resume_from,
)
else:
# Queen-only session
@@ -144,6 +148,7 @@ async def handle_create_session(request: web.Request) -> web.Response:
session_id=session_id,
model=model,
initial_prompt=initial_prompt,
queen_resume_from=queen_resume_from,
)
except ValueError as e:
msg = str(e)
@@ -674,17 +679,49 @@ async def handle_session_history(request: web.Request) -> web.Response:
server restart have ``live: false, cold: true``.
"""
manager = _get_manager(request)
live_ids = {s.id for s in manager.list_sessions()}
live_sessions = {s.id: s for s in manager.list_sessions()}
disk_sessions = SessionManager.list_cold_sessions()
for s in disk_sessions:
if s["session_id"] in live_ids:
if s["session_id"] in live_sessions:
live = live_sessions[s["session_id"]]
s["cold"] = False
s["live"] = True
# Fill in agent_name from live memory if meta.json wasn't written yet
if not s.get("agent_name") and live.worker_info:
s["agent_name"] = live.worker_info.name
if not s.get("agent_path") and live.worker_path:
s["agent_path"] = str(live.worker_path)
return web.json_response({"sessions": disk_sessions})
async def handle_delete_history_session(request: web.Request) -> web.Response:
"""DELETE /api/sessions/history/{session_id} — permanently remove a session.
Stops the live session (if still running) and deletes the queen session
directory from disk at ~/.hive/queen/session/{session_id}/.
This is the frontend 'delete from history' action.
"""
manager = _get_manager(request)
session_id = request.match_info["session_id"]
# Stop the live session if it exists (best-effort)
if manager.get_session(session_id):
await manager.stop_session(session_id)
# Delete the queen session directory from disk
queen_session_dir = Path.home() / ".hive" / "queen" / "session" / session_id
if queen_session_dir.exists() and queen_session_dir.is_dir():
try:
shutil.rmtree(queen_session_dir)
except OSError as e:
logger.warning("Failed to delete session directory %s: %s", queen_session_dir, e)
return web.json_response({"error": f"Failed to delete session: {e}"}, status=500)
return web.json_response({"deleted": session_id})
# ------------------------------------------------------------------
# Agent discovery (not session-specific)
# ------------------------------------------------------------------
@@ -733,6 +770,7 @@ def register_routes(app: web.Application) -> None:
app.router.add_get("/api/sessions", handle_list_live_sessions)
# history must be registered before {session_id} so it takes priority
app.router.add_get("/api/sessions/history", handle_session_history)
app.router.add_delete("/api/sessions/history/{session_id}", handle_delete_history_session)
app.router.add_get("/api/sessions/{session_id}", handle_get_live_session)
app.router.add_delete("/api/sessions/{session_id}", handle_stop_session)
+134 -16
View File
@@ -43,6 +43,11 @@ class Session:
# Judge (active when worker is loaded)
judge_task: asyncio.Task | None = None
escalation_sub: str | None = None
# Session directory resumption:
# When set, _start_queen writes queen conversations to this existing session's
# directory instead of creating a new one. This lets cold-restores accumulate
# all messages in the original session folder so history is never fragmented.
queen_resume_from: str | None = None
class SessionManager:
@@ -111,18 +116,21 @@ class SessionManager:
session_id: str | None = None,
model: str | None = None,
initial_prompt: str | None = None,
queen_resume_from: str | None = None,
) -> Session:
"""Create a new session with a queen but no worker.
The queen starts immediately with MCP coding tools.
A worker can be loaded later via load_worker().
When ``queen_resume_from`` is set the queen writes conversation messages
to that existing session's directory instead of creating a new one.
This preserves full conversation history across server restarts.
"""
session = await self._create_session_core(session_id=session_id, model=model)
session.queen_resume_from = queen_resume_from
# Start queen immediately (queen-only, no worker tools yet)
await self._start_queen(session, worker_identity=None, initial_prompt=initial_prompt)
logger.info("Session '%s' created (queen-only)", session.id)
logger.info("Session '%s' created (queen-only, resume_from=%s)", session.id, queen_resume_from)
return session
async def create_session_with_worker(
@@ -131,15 +139,12 @@ class SessionManager:
agent_id: str | None = None,
model: str | None = None,
initial_prompt: str | None = None,
queen_resume_from: str | None = None,
) -> Session:
"""Create a session and load a worker in one step.
Backward-compatible with the old POST /api/agents flow.
Loads the worker FIRST so the queen starts with full lifecycle
and monitoring tools available.
The session gets an auto-generated unique ID. The agent name
becomes the worker_id (used by the frontend as backendAgentId).
When ``queen_resume_from`` is set the queen writes conversation messages
to that existing session's directory instead of creating a new one.
"""
from framework.tools.queen_lifecycle_tools import build_worker_profile
@@ -148,6 +153,7 @@ class SessionManager:
# Auto-generate session ID (not the agent name)
session = await self._create_session_core(model=model)
session.queen_resume_from = queen_resume_from
try:
# Load worker FIRST (before queen) so queen gets full tools
await self._load_worker_core(
@@ -167,10 +173,6 @@ class SessionManager:
session, worker_identity=worker_identity, initial_prompt=initial_prompt
)
# Health judge disabled for simplicity.
# if agent_path.name != "hive_coder" and session.worker_runtime:
# await self._start_judge(session, session.runner._storage_path)
except Exception:
# If anything fails, tear down the session
await self.stop_session(session.id)
@@ -397,7 +399,12 @@ class SessionManager:
worker_identity: str | None,
initial_prompt: str | None = None,
) -> None:
"""Start the queen executor for a session."""
"""Start the queen executor for a session.
When ``session.queen_resume_from`` is set, queen conversation messages
are written to the ORIGINAL session's directory so the full conversation
history accumulates in one place across server restarts.
"""
from framework.agents.hive_coder.agent import (
queen_goal,
queen_graph as _queen_graph,
@@ -407,9 +414,41 @@ class SessionManager:
from framework.runtime.core import Runtime
hive_home = Path.home() / ".hive"
queen_dir = hive_home / "queen" / "session" / session.id
# Determine which session directory to use for queen storage.
# When queen_resume_from is set we write to the ORIGINAL session's
# directory so that all messages accumulate in one place.
storage_session_id = session.queen_resume_from or session.id
queen_dir = hive_home / "queen" / "session" / storage_session_id
queen_dir.mkdir(parents=True, exist_ok=True)
# Always write/update session metadata so history sidebar has correct
# agent name, path, and last-active timestamp (important so the original
# session directory sorts as "most recent" after a cold-restore resume).
_meta_path = queen_dir / "meta.json"
try:
_agent_name = (
session.worker_info.name
if session.worker_info
else (
str(session.worker_path.name).replace("_", " ").title()
if session.worker_path
else None
)
)
_meta_path.write_text(
json.dumps(
{
"agent_name": _agent_name,
"agent_path": str(session.worker_path) if session.worker_path else None,
"created_at": time.time(),
}
),
encoding="utf-8",
)
except OSError:
pass
# Register MCP coding tools
queen_registry = ToolRegistry()
import framework.agents.hive_coder as _hive_coder_pkg
@@ -744,12 +783,27 @@ class SessionManager:
except OSError:
created_at = 0.0
# Read extra metadata written at session start
agent_name: str | None = None
agent_path: str | None = None
meta_path = queen_dir / "meta.json"
if meta_path.exists():
try:
meta = json.loads(meta_path.read_text(encoding="utf-8"))
agent_name = meta.get("agent_name")
agent_path = meta.get("agent_path")
created_at = meta.get("created_at") or created_at
except (json.JSONDecodeError, OSError):
pass
return {
"session_id": session_id,
"cold": True,
"live": False,
"has_messages": has_messages,
"created_at": created_at,
"agent_name": agent_name,
"agent_path": agent_path,
}
@staticmethod
@@ -776,12 +830,76 @@ class SessionManager:
created_at = d.stat().st_ctime
except OSError:
created_at = 0.0
agent_name: str | None = None
agent_path: str | None = None
meta_path = d / "meta.json"
if meta_path.exists():
try:
meta = json.loads(meta_path.read_text(encoding="utf-8"))
agent_name = meta.get("agent_name")
agent_path = meta.get("agent_path")
created_at = meta.get("created_at") or created_at
except (json.JSONDecodeError, OSError):
pass
# Build a quick preview of the last human/assistant exchange.
# We read all conversation parts, filter to client-facing messages,
# and return the last assistant message content as a snippet.
last_message: str | None = None
message_count: int = 0
convs_dir = d / "conversations"
if convs_dir.exists():
try:
all_parts: list[dict] = []
for node_dir in convs_dir.iterdir():
if not node_dir.is_dir():
continue
parts_dir = node_dir / "parts"
if not parts_dir.exists():
continue
for part_file in sorted(parts_dir.iterdir()):
if part_file.suffix != ".json":
continue
try:
part = json.loads(part_file.read_text(encoding="utf-8"))
part.setdefault("created_at", part_file.stat().st_mtime)
all_parts.append(part)
except (json.JSONDecodeError, OSError):
continue
# Filter to client-facing messages only
client_msgs = [
p for p in all_parts
if not p.get("is_transition_marker")
and p.get("role") != "tool"
and not (p.get("role") == "assistant" and p.get("tool_calls"))
]
client_msgs.sort(key=lambda m: m.get("created_at", m.get("seq", 0)))
message_count = len(client_msgs)
# Last assistant message as preview snippet
for msg in reversed(client_msgs):
content = msg.get("content") or ""
if isinstance(content, list):
# Anthropic-style content blocks
content = " ".join(
b.get("text", "") for b in content
if isinstance(b, dict) and b.get("type") == "text"
)
if content and msg.get("role") == "assistant":
last_message = content[:120].strip()
break
except OSError:
pass
results.append({
"session_id": d.name,
"cold": True, # caller overrides for live sessions
"live": False,
"has_messages": (d / "conversations").exists(),
"has_messages": convs_dir.exists() and message_count > 0,
"created_at": created_at,
"agent_name": agent_name,
"agent_path": agent_path,
"last_message": last_message,
"message_count": message_count,
})
return results
+7 -2
View File
@@ -13,12 +13,13 @@ export const sessionsApi = {
// --- Session lifecycle ---
/** Create a session. If agentPath is provided, loads worker in one step. */
create: (agentPath?: string, agentId?: string, model?: string, initialPrompt?: string) =>
create: (agentPath?: string, agentId?: string, model?: string, initialPrompt?: string, queenResumeFrom?: string) =>
api.post<LiveSession>("/sessions", {
agent_path: agentPath,
agent_id: agentId,
model,
initial_prompt: initialPrompt,
queen_resume_from: queenResumeFrom || undefined,
}),
/** List all active sessions. */
@@ -72,7 +73,11 @@ export const sessionsApi = {
/** List all queen sessions on disk — live + cold (post-restart). */
history: () =>
api.get<{ sessions: Array<{ session_id: string; cold: boolean; live: boolean; has_messages: boolean; created_at: number }> }>("/sessions/history"),
api.get<{ sessions: Array<{ session_id: string; cold: boolean; live: boolean; has_messages: boolean; created_at: number; agent_name?: string | null; agent_path?: string | null }> }>("/sessions/history"),
/** Permanently delete a history session (stops live session + removes disk files). */
deleteHistory: (sessionId: string) =>
api.delete<{ deleted: string }>(`/sessions/history/${sessionId}`),
// --- Worker session browsing (persisted execution runs) ---
@@ -0,0 +1,431 @@
/**
* HistorySidebar — persistent ChatGPT-style session history sidebar.
*
* Shown on both the Home page and the Workspace. Clicking a session fires
* `onOpen(sessionId, agentPath)` so the caller decides what to do (navigate
* to workspace on Home, open/switch tab on Workspace).
*
* Labels (user-visible names) are stored purely in localStorage — backend
* session IDs are never touched.
*
* Session deduplication: the backend may have multiple session directories
* for the same agent (cold restarts create new directories). We deduplicate
* by agent_path and show only the most-recent session per agent so the
* history list stays clean.
*/
import { useState, useEffect, useRef, useCallback } from "react";
import { ChevronLeft, ChevronRight, Clock, Bot, Loader2, MoreHorizontal, Pencil, Trash2, Check, X } from "lucide-react";
import { sessionsApi } from "@/api/sessions";
// ── Types ─────────────────────────────────────────────────────────────────────
export type HistorySession = {
session_id: string;
cold: boolean;
live: boolean;
has_messages: boolean;
created_at: number;
agent_name?: string | null;
agent_path?: string | null;
/** Snippet of the last assistant message — for sidebar preview. */
last_message?: string | null;
/** Total number of client-facing messages in this session. */
message_count?: number;
};
const LABEL_STORE_KEY = "hive:history-labels";
function loadLabelStore(): Record<string, string> {
try {
const raw = localStorage.getItem(LABEL_STORE_KEY);
return raw ? (JSON.parse(raw) as Record<string, string>) : {};
} catch {
return {};
}
}
function saveLabelStore(store: Record<string, string>) {
try {
localStorage.setItem(LABEL_STORE_KEY, JSON.stringify(store));
} catch { }
}
// ── Helpers ───────────────────────────────────────────────────────────────────
function defaultLabel(s: HistorySession, index: number): string {
if (s.agent_name) return s.agent_name;
if (s.agent_path) {
const base = s.agent_path.replace(/\/$/, "").split("/").pop() || s.agent_path;
return base
.split("_")
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(" ");
}
return `New Agent${index > 0 ? ` #${index + 1}` : ""}`;
}
function formatDateTime(createdAt: number, sessionId: string): string {
// Prefer timestamp embedded in session_id: session_YYYYMMDD_HHMMSS_xxx
const match = sessionId.match(/^session_(\d{4})(\d{2})(\d{2})_(\d{2})(\d{2})(\d{2})/);
const d = match
? new Date(+match[1], +match[2] - 1, +match[3], +match[4], +match[5], +match[6])
: new Date(createdAt * 1000);
return d.toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
/**
* Deduplicate sessions by agent_path — keep only the most recent session
* per agent. Sessions are already sorted newest-first by the backend.
* Sessions without an agent_path (new-agent / queen-only) are kept individually.
*/
function deduplicateByAgent(sessions: HistorySession[]): HistorySession[] {
const seen = new Set<string>();
const result: HistorySession[] = [];
for (const s of sessions) {
// Group key: use agent_path when present, otherwise use session_id (unique)
const key = s.agent_path ? s.agent_path.replace(/\/$/, "") : `__no_agent__${s.session_id}`;
if (!seen.has(key)) {
seen.add(key);
result.push(s);
}
// Additional sessions for the same agent are silently skipped
}
return result;
}
function groupByDate(sessions: HistorySession[]): { label: string; items: HistorySession[] }[] {
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
const yesterday = today - 86_400_000;
const weekAgo = today - 7 * 86_400_000;
const groups: { label: string; items: HistorySession[] }[] = [
{ label: "Today", items: [] },
{ label: "Yesterday", items: [] },
{ label: "Last 7 days", items: [] },
{ label: "Older", items: [] },
];
for (const s of sessions) {
const d = new Date(s.created_at * 1000);
const dayTs = new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
if (dayTs >= today) groups[0].items.push(s);
else if (dayTs >= yesterday) groups[1].items.push(s);
else if (dayTs >= weekAgo) groups[2].items.push(s);
else groups[3].items.push(s);
}
return groups.filter((g) => g.items.length > 0);
}
// ── Row component ─────────────────────────────────────────────────────────────
interface RowProps {
session: HistorySession;
label: string;
index: number;
isActive: boolean;
isLive: boolean;
onOpen: () => void;
onRename: (newLabel: string) => void;
onDelete: () => void;
}
function HistoryRow({ session: s, label, isActive, isLive, onOpen, onRename, onDelete }: RowProps) {
const [menuOpen, setMenuOpen] = useState(false);
const [renaming, setRenaming] = useState(false);
const [draftLabel, setDraftLabel] = useState(label);
const menuRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (!menuOpen) return;
const handler = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) setMenuOpen(false);
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, [menuOpen]);
useEffect(() => {
if (renaming) {
setDraftLabel(label);
requestAnimationFrame(() => inputRef.current?.select());
}
}, [renaming, label]);
const commitRename = () => {
const trimmed = draftLabel.trim();
if (trimmed) onRename(trimmed);
setRenaming(false);
};
const dateStr = formatDateTime(s.created_at, s.session_id);
return (
<div
className={`group relative flex items-start gap-2 px-3 py-2 cursor-pointer transition-colors ${isActive
? "bg-primary/10 border-l-2 border-primary"
: "border-l-2 border-transparent hover:bg-muted/40"
}`}
onClick={() => { if (!renaming) onOpen(); }}
>
<Bot className="w-3.5 h-3.5 flex-shrink-0 mt-[3px] text-muted-foreground/40 group-hover:text-muted-foreground/70 transition-colors" />
<div className="min-w-0 flex-1">
{renaming ? (
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
<input
ref={inputRef}
value={draftLabel}
onChange={(e) => setDraftLabel(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") commitRename();
if (e.key === "Escape") setRenaming(false);
}}
className="flex-1 min-w-0 text-[11px] bg-muted/60 border border-border/50 rounded px-1.5 py-0.5 text-foreground focus:outline-none focus:ring-1 focus:ring-primary/40"
/>
<button onClick={commitRename} className="p-0.5 text-primary hover:text-primary/80">
<Check className="w-3 h-3" />
</button>
<button onClick={() => setRenaming(false)} className="p-0.5 text-muted-foreground hover:text-foreground">
<X className="w-3 h-3" />
</button>
</div>
) : (
<>
<div className={`text-[11px] font-medium truncate leading-tight ${isActive ? "text-foreground" : "text-foreground/80"}`}>
{label}
</div>
{/* Message preview — most recent assistant message */}
{s.last_message && (
<div className="text-[10px] text-muted-foreground/50 mt-0.5 leading-tight line-clamp-2 break-words">
{s.last_message}
</div>
)}
<div className="flex items-center gap-1.5 mt-0.5">
<div className="text-[10px] text-muted-foreground/40">{dateStr}</div>
{(s.message_count ?? 0) > 0 && (
<span className="text-[9px] text-muted-foreground/30">· {s.message_count} msgs</span>
)}
</div>
{isLive && (
<span className="text-[9px] text-emerald-500/80 font-semibold uppercase tracking-wide">live</span>
)}
</>
)}
</div>
{/* 3-dot button — visible on row hover */}
{!renaming && (
<div className="relative flex-shrink-0" ref={menuRef} onClick={(e) => e.stopPropagation()}>
<button
onClick={() => setMenuOpen((o) => !o)}
className={`p-0.5 rounded transition-colors text-muted-foreground/40 hover:text-foreground hover:bg-muted/60 ${menuOpen ? "opacity-100" : "opacity-0 group-hover:opacity-100"
}`}
title="More options"
>
<MoreHorizontal className="w-3.5 h-3.5" />
</button>
{menuOpen && (
<div className="absolute right-0 top-5 z-50 w-36 rounded-lg border border-border/60 bg-card shadow-xl shadow-black/30 overflow-hidden py-1">
<button
onClick={() => { setMenuOpen(false); setRenaming(true); }}
className="flex items-center gap-2 w-full px-3 py-1.5 text-xs text-foreground hover:bg-muted/60 transition-colors"
>
<Pencil className="w-3 h-3 text-muted-foreground" />
Rename
</button>
<button
onClick={() => { setMenuOpen(false); onDelete(); }}
className="flex items-center gap-2 w-full px-3 py-1.5 text-xs text-destructive hover:bg-destructive/10 transition-colors"
>
<Trash2 className="w-3 h-3" />
Delete
</button>
</div>
)}
</div>
)}
</div>
);
}
// ── Main sidebar component ────────────────────────────────────────────────────
interface HistorySidebarProps {
/** Called when a history session is clicked. */
onOpen: (sessionId: string, agentPath?: string | null, agentName?: string | null) => void;
/** session_ids of tabs already open (for highlighting). */
openSessionIds?: string[];
/** session_id of the currently active/viewed session (live backend ID). */
activeSessionId?: string | null;
/** historySourceId of the active session — the original cold session ID before revive,
* stays stable even after the backend creates a new live session on cold-restore. */
activeHistorySourceId?: string | null;
/** Increment this to force a refresh of the session list. */
refreshKey?: number;
}
export default function HistorySidebar({ onOpen, openSessionIds = [], activeSessionId, activeHistorySourceId, refreshKey }: HistorySidebarProps) {
const [collapsed, setCollapsed] = useState(false);
// Raw sessions from the backend (may contain duplicates per agent)
const [rawSessions, setRawSessions] = useState<HistorySession[]>([]);
const [loading, setLoading] = useState(false);
const [labels, setLabels] = useState<Record<string, string>>(loadLabelStore);
const refresh = useCallback(() => {
setLoading(true);
sessionsApi
.history()
.then((r) => setRawSessions(r.sessions))
.catch(() => { })
.finally(() => setLoading(false));
}, []);
// Refresh on mount and whenever the parent forces a refresh
useEffect(() => {
refresh();
}, [refresh, refreshKey]);
// Refresh when the browser tab regains visibility
useEffect(() => {
const handleVisibility = () => {
if (document.visibilityState === "visible") refresh();
};
document.addEventListener("visibilitychange", handleVisibility);
return () => document.removeEventListener("visibilitychange", handleVisibility);
}, [refresh]);
const handleRename = (sessionId: string, newLabel: string) => {
const next = { ...labels, [sessionId]: newLabel };
setLabels(next);
saveLabelStore(next);
};
const handleDelete = (sessionId: string) => {
// Optimistically remove from in-memory list immediately
setRawSessions((prev) => prev.filter((s) => s.session_id !== sessionId));
const next = { ...labels };
delete next[sessionId];
setLabels(next);
saveLabelStore(next);
// Permanently delete session files from disk (fire-and-forget)
sessionsApi.deleteHistory(sessionId).catch(() => {
// Soft failure — the entry is already removed from the UI.
// The file may linger on disk, but won't appear in the next refresh
// because it's been removed from rawSessions.
});
};
// ── Deduplicate & render ────────────────────────────────────────────────────
// Deduplicate: show only the most-recent session per agent_path.
// rawSessions is already sorted newest-first by the backend.
const sessions = deduplicateByAgent(rawSessions);
const groups = groupByDate(sessions);
return (
<div
className={`flex-shrink-0 flex flex-col bg-card/20 border-r border-border/30 transition-[width] duration-200 overflow-hidden ${collapsed ? "w-[44px]" : "w-[220px]"
}`}
>
{/* Header */}
<div
className={`flex items-center border-b border-border/20 flex-shrink-0 h-10 ${collapsed ? "justify-center" : "px-3 gap-2"
}`}
>
{!collapsed && (
<span className="text-[11px] font-semibold text-muted-foreground/60 uppercase tracking-wider flex-1">
History
</span>
)}
<button
onClick={() => setCollapsed((o) => !o)}
className="p-1 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors flex-shrink-0"
title={collapsed ? "Expand history" : "Collapse history"}
>
{collapsed ? (
<ChevronRight className="w-3.5 h-3.5" />
) : (
<ChevronLeft className="w-3.5 h-3.5" />
)}
</button>
</div>
{/* Expanded list */}
{!collapsed && (
<div className="flex-1 overflow-y-auto min-h-0">
{loading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground/40" />
</div>
) : sessions.length === 0 ? (
<div className="px-4 py-12 text-center text-[11px] text-muted-foreground/40 leading-relaxed">
No previous
<br />
sessions yet
</div>
) : (
groups.map(({ label: groupLabel, items }) => (
<div key={groupLabel}>
<p className="px-3 pt-4 pb-1 text-[10px] font-semibold text-muted-foreground/35 uppercase tracking-wider">
{groupLabel}
</p>
{items.map((s, idx) => {
const customLabel = labels[s.session_id];
const computedLabel = customLabel || defaultLabel(s, idx);
const isActive =
s.session_id === activeSessionId ||
s.session_id === activeHistorySourceId;
// Mark as live if the backend flagged it OR if it's currently open in a tab
const isLive = s.live || openSessionIds.includes(s.session_id);
return (
<HistoryRow
key={s.session_id}
session={s}
label={computedLabel}
index={idx}
isActive={isActive}
isLive={isLive}
onOpen={() => onOpen(s.session_id, s.agent_path, s.agent_name)}
onRename={(nl) => handleRename(s.session_id, nl)}
onDelete={() => handleDelete(s.session_id)}
/>
);
})}
</div>
))
)}
</div>
)}
{/* Collapsed icon strip */}
{collapsed && (
<div className="flex-1 overflow-y-auto min-h-0 flex flex-col items-center py-2 gap-0.5">
{sessions.slice(0, 30).map((s) => {
const isLive = s.live || openSessionIds.includes(s.session_id);
return (
<button
key={s.session_id}
onClick={() => { setCollapsed(false); onOpen(s.session_id, s.agent_path, s.agent_name); }}
className="w-7 h-7 rounded-md flex items-center justify-center text-muted-foreground/40 hover:text-foreground hover:bg-muted/50 transition-colors relative"
title={labels[s.session_id] || defaultLabel(s, 0)}
>
<Clock className="w-3 h-3" />
{isLive && (
<span className="absolute top-0.5 right-0.5 w-1.5 h-1.5 rounded-full bg-emerald-500" />
)}
</button>
);
})}
</div>
)}
</div>
);
}
+1 -1
View File
@@ -9,7 +9,7 @@ import type { GraphNode } from "@/components/AgentGraph";
export const TAB_STORAGE_KEY = "hive:workspace-tabs";
export interface PersistedTabState {
tabs: Array<{ id: string; agentType: string; tabKey?: string; label: string; backendSessionId?: string }>;
tabs: Array<{ id: string; agentType: string; tabKey?: string; label: string; backendSessionId?: string; historySourceId?: string }>;
activeSessionByAgent: Record<string, string>;
activeWorker: string;
sessions?: Record<string, { messages: ChatMessage[]; graphNodes: GraphNode[] }>;
+15 -4
View File
@@ -2,6 +2,7 @@ import { useState, useEffect, useRef } from "react";
import { useNavigate } from "react-router-dom";
import { Crown, Mail, Briefcase, Shield, Search, Newspaper, ArrowRight, Hexagon, Send, Bot } from "lucide-react";
import TopBar from "@/components/TopBar";
import HistorySidebar from "@/components/HistorySidebar";
import type { LucideIcon } from "lucide-react";
import { agentsApi } from "@/api/agents";
import type { DiscoverEntry } from "@/api/types";
@@ -79,12 +80,21 @@ export default function Home() {
};
return (
<div className="min-h-screen bg-background flex flex-col">
<div className="min-h-screen h-screen bg-background flex flex-col overflow-hidden">
<TopBar />
{/* Main content */}
<div className="flex-1 flex flex-col items-center justify-center p-6">
<div className="w-full max-w-2xl">
{/* Body: sidebar + main */}
<div className="flex flex-1 min-h-0">
{/* Persistent history sidebar */}
<HistorySidebar
onOpen={(sessionId, agentPath) => {
navigate(`/workspace?agent=${encodeURIComponent(agentPath || "new-agent")}&session=${encodeURIComponent(sessionId)}`);
}}
/>
{/* Main content */}
<div className="flex-1 flex flex-col items-center justify-center p-6 overflow-y-auto">
<div className="w-full max-w-2xl">
{/* Queen Bee greeting */}
<div className="text-center mb-8">
<div
@@ -234,6 +244,7 @@ export default function Home() {
</div>
)}
</div>
</div>
</div>
</div>
);
+368 -87
View File
@@ -8,6 +8,7 @@ import TopBar from "@/components/TopBar";
import { TAB_STORAGE_KEY, loadPersistedTabs, savePersistedTabs, type PersistedTabState } from "@/lib/tab-persistence";
import NodeDetailPanel from "@/components/NodeDetailPanel";
import CredentialsModal, { type Credential, createFreshCredentials, cloneCredentials, allRequiredCredentialsMet, clearCredentialCache } from "@/components/CredentialsModal";
import HistorySidebar from "@/components/HistorySidebar";
import { agentsApi } from "@/api/agents";
import { executionApi } from "@/api/execution";
import { graphsApi } from "@/api/graphs";
@@ -71,6 +72,10 @@ interface Session {
graphNodes: GraphNode[];
credentials: Credential[];
backendSessionId?: string;
/** The cold history session ID this tab was originally opened from (if any).
* Used to detect "already open" even after backendSessionId is updated to a
* new live session ID when the cold session is revived. */
historySourceId?: string;
}
function createSession(agentType: string, label: string, existingCredentials?: Credential[]): Session {
@@ -294,6 +299,9 @@ export default function Workspace() {
const initialAgent = rawAgent;
const hasExplicitAgent = searchParams.has("agent");
const initialPrompt = searchParams.get("prompt") || "";
// ?session= param: when navigating from the home history sidebar, this
// carries the backendSessionId to open as a tab on mount.
const initialSessionId = searchParams.get("session") || "";
// Sessions grouped by agent type — restore from localStorage if available
const [sessionsByAgent, setSessionsByAgent] = useState<Record<string, Session[]>>(() => {
@@ -310,6 +318,7 @@ export default function Workspace() {
session.id = tab.id;
session.backendSessionId = tab.backendSessionId;
session.tabKey = tab.tabKey; // restore so future persistence uses correct key
session.historySourceId = tab.historySourceId;
// Restore messages and graph from localStorage (up to 50 messages).
// If the backend session is still alive, loadAgentForType may
// append additional messages fetched from the server.
@@ -328,15 +337,23 @@ export default function Workspace() {
return initial;
}
// If there are already persisted tabs for this agent type, don't create
// a new one — the post-mount effect will call handleHistoryOpen if needed
// (for ?session= params coming from the home page sidebar).
if (initial[initialAgent]?.length) {
return initial;
}
// Only create a fresh default tab when there are no persisted tabs at all.
// If ?session= was passed we intentionally do NOT create a tab here —
// handleHistoryOpen is called post-mount and does proper dedup.
if (initialAgent === "new-agent") {
initial["new-agent"] = [...(initial["new-agent"] || []), createSession("new-agent", "New Agent")];
} else {
initial[initialAgent] = [...(initial[initialAgent] || []),
createSession(initialAgent, formatAgentDisplayName(initialAgent))];
const s = createSession("new-agent", "New Agent");
initial["new-agent"] = [...(initial["new-agent"] || []), s];
} else if (!initialSessionId) {
// Only auto-create an agent tab if there's no session to restore
const s = createSession(initialAgent, formatAgentDisplayName(initialAgent));
initial[initialAgent] = [...(initial[initialAgent] || []), s];
}
return initial;
@@ -344,6 +361,17 @@ export default function Workspace() {
const [activeSessionByAgent, setActiveSessionByAgent] = useState<Record<string, string>>(() => {
const persisted = loadPersistedTabs();
// If initialSessionId maps to an already-restored tab, activate that tab
if (initialSessionId) {
for (const [tabKey, sessions] of Object.entries(sessionsByAgent)) {
const match = sessions.find(
s => s.backendSessionId === initialSessionId || s.historySourceId === initialSessionId,
);
if (match) {
return { ...(persisted?.activeSessionByAgent ?? {}), [tabKey]: match.id };
}
}
}
if (persisted) {
const restored = { ...persisted.activeSessionByAgent };
const urlSessions = sessionsByAgent[initialAgent];
@@ -357,6 +385,14 @@ export default function Workspace() {
});
const [activeWorker, setActiveWorker] = useState(() => {
// If initialSessionId maps to an already-restored tab, activate that key
if (initialSessionId) {
for (const [tabKey, sessions] of Object.entries(sessionsByAgent)) {
if (sessions.some(
s => s.backendSessionId === initialSessionId || s.historySourceId === initialSessionId,
)) return tabKey;
}
}
if (!hasExplicitAgent) {
const persisted = loadPersistedTabs();
if (persisted?.activeWorker) return persisted.activeWorker;
@@ -370,6 +406,19 @@ export default function Workspace() {
navigate("/workspace", { replace: true });
}, []);
// --- Sidebar refresh key: increment to force HistorySidebar to re-poll ---
const [historySidebarRefreshKey, setHistorySidebarRefreshKey] = useState(0);
// Post-mount: if the URL carried a ?session= param (from the home page history
// sidebar), open it via handleHistoryOpen instead of creating a tab in init state.
// This is the single canonical path — it has robust dedup (checks backendSessionId
// AND historySourceId across all in-memory tabs) and is safe to call after persisted
// state has been hydrated.
// We capture initialSessionId and related URL params in stable refs so the effect
// only fires once on mount, regardless of re-renders.
const initialSessionIdRef = useRef(initialSessionId);
const initialAgentRef = useRef(initialAgent);
const mountedRef = useRef(false);
const [credentialsOpen, setCredentialsOpen] = useState(false);
// Explicit agent path for the credentials modal — set from 424 responses
// when activeWorker doesn't match the actual agent (e.g. "new-agent" tab).
@@ -395,6 +444,12 @@ export default function Workspace() {
// arrive in the same React batch.
const turnCounterRef = useRef<Record<string, number>>({});
// Synchronous ref to suppress the queen's auto-intro SSE messages
// after a cold-restore (where we already restored the conversation from disk).
// Using a ref avoids the race condition where sessionId is set in agentState
// (opening SSE) before the suppressQueenIntro flag can be committed.
const suppressIntroRef = useRef(new Set<string>());
// --- Consolidated per-agent backend state ---
const [agentStates, setAgentStates] = useState<Record<string, AgentBackendState>>({});
@@ -426,6 +481,7 @@ export default function Workspace() {
label: s.label,
// agentStates is keyed by tabKey (unique per tab), not by base agentType
backendSessionId: s.backendSessionId || agentStates[tKey]?.sessionId || undefined,
...(s.historySourceId ? { historySourceId: s.historySourceId } : {}),
});
sessions[s.id] = { messages: s.messages, graphNodes: s.graphNodes };
}
@@ -485,7 +541,7 @@ export default function Workspace() {
const { Framework: _fw, ...userFacing } = result;
const all = Object.values(userFacing).flat();
setDiscoverAgents(all);
}).catch(() => {});
}).catch(() => { });
}, []);
// --- Agent loading: loadAgentForType ---
@@ -522,50 +578,57 @@ export default function Workspace() {
}
}
if (!liveSession) {
liveSession = await sessionsApi.create(undefined, undefined, undefined, prompt);
let restoredMessageCount = 0;
// Restore previous conversation from disk when the server restarted
// (coldRestoreId) or the session ID was stored but is gone (storedId).
if (!liveSession) {
// Fetch conversation history from disk BEFORE creating the new session.
// SKIP if messages were already pre-populated by handleHistoryOpen.
const restoreFrom = coldRestoreId ?? storedId;
let restoredMessageCount = 0;
if (restoreFrom) {
const preRestoredMsgs: ChatMessage[] = [];
const alreadyHasMessages = (sessionsRef.current[agentType] || [])[0]?.messages?.length > 0;
if (restoreFrom && !alreadyHasMessages) {
try {
const { messages: queenMsgs } = await sessionsApi.queenMessages(restoreFrom);
if (queenMsgs.length > 0) {
const restoredMsgs = (queenMsgs as Message[]).map(m => {
const msg = backendMessageToChatMessage(m, agentType, "Queen Bee");
msg.role = "queen";
return msg;
});
restoredMsgs.sort((a, b) => (a.createdAt ?? 0) - (b.createdAt ?? 0));
setSessionsByAgent(prev => ({
...prev,
[agentType]: (prev[agentType] || []).map((s, i) =>
i === 0 ? { ...s, messages: restoredMsgs, graphNodes: [] } : s,
),
}));
restoredMessageCount = restoredMsgs.length;
} else {
// No messages on disk — wipe stale localStorage cache
setSessionsByAgent(prev => ({
...prev,
[agentType]: (prev[agentType] || []).map((s, i) =>
i === 0 ? { ...s, messages: [], graphNodes: [] } : s,
),
}));
for (const m of queenMsgs as Message[]) {
const msg = backendMessageToChatMessage(m, agentType, "Queen Bee");
msg.role = "queen";
preRestoredMsgs.push(msg);
}
} catch {
// Queen messages unavailable — wipe stale cache
setSessionsByAgent(prev => ({
...prev,
[agentType]: (prev[agentType] || []).map((s, i) =>
i === 0 ? { ...s, messages: [], graphNodes: [] } : s,
),
}));
// Not available — will start fresh
}
}
// Suppress the queen's intro cycle whenever we are about to restore a
// previous conversation, or whenever we have a stored session ID (even if
// its files are gone) — the user never expects a greeting on reopen.
const willRestore = !!(restoreFrom);
if (willRestore || preRestoredMsgs.length > 0) suppressIntroRef.current.add(agentType);
// Pass coldRestoreId as queenResumeFrom so the backend writes queen
// messages into the ORIGINAL session's directory — all conversation
// history accumulates in one place across server restarts.
liveSession = await sessionsApi.create(undefined, undefined, undefined, prompt, coldRestoreId ?? undefined);
if (preRestoredMsgs.length > 0) {
preRestoredMsgs.sort((a, b) => (a.createdAt ?? 0) - (b.createdAt ?? 0));
setSessionsByAgent(prev => ({
...prev,
[agentType]: (prev[agentType] || []).map((s, i) =>
i === 0 ? { ...s, messages: preRestoredMsgs, graphNodes: [] } : s,
),
}));
restoredMessageCount = preRestoredMsgs.length;
} else if (restoreFrom) {
// We had a stored session but no messages on disk — wipe stale localStorage cache
setSessionsByAgent(prev => ({
...prev,
[agentType]: (prev[agentType] || []).map((s, i) =>
i === 0 ? { ...s, messages: [], graphNodes: [] } : s,
),
}));
}
// Show the initial prompt as a user message only on a truly fresh session
if (prompt && restoredMessageCount === 0) {
const userMsg: ChatMessage = {
@@ -581,14 +644,23 @@ export default function Workspace() {
}
}
// Store backendSessionId on the Session object for persistence
// Store backendSessionId on the Session object for persistence.
// Also set historySourceId so the sidebar \"already-open\" check works
// even after cold-revive changes backendSessionId to a new live session ID.
setSessionsByAgent(prev => ({
...prev,
[agentType]: (prev[agentType] || []).map((s, i) =>
i === 0 ? { ...s, backendSessionId: liveSession!.session_id } : s,
i === 0 ? {
...s,
backendSessionId: liveSession!.session_id,
historySourceId: s.historySourceId || coldRestoreId || undefined,
} : s,
),
}));
// If no messages were actually restored, lift the intro suppression
if (restoredMessageCount === 0) suppressIntroRef.current.delete(agentType);
updateAgentState(agentType, {
sessionId: liveSession.session_id,
displayName: "Queen Bee",
@@ -612,22 +684,42 @@ export default function Workspace() {
try {
let liveSession: LiveSession | undefined;
let isResumedSession = false;
// Set when the stored session is cold (server restarted) so we can restore
// messages from the old session files after creating a new live session.
let coldRestoreId: string | undefined;
// Try to reconnect to an existing backend session (e.g., after browser refresh).
// The backendSessionId is persisted in localStorage per tab.
const storedSessionId = sessionsRef.current[agentType]?.[0]?.backendSessionId;
// Also check historySourceId — handleHistoryOpen populates this with the
// original session ID from the sidebar. Use it as a fallback for stored ID.
const historySourceId = sessionsRef.current[agentType]?.[0]?.historySourceId;
const storedSessionId = sessionsRef.current[agentType]?.[0]?.backendSessionId
|| historySourceId;
if (storedSessionId) {
try {
liveSession = await sessionsApi.get(storedSessionId);
isResumedSession = true;
const sessionData = await sessionsApi.get(storedSessionId);
if (sessionData.cold) {
// Server restarted — conversation files survive on disk, no live runtime.
coldRestoreId = storedSessionId;
} else {
liveSession = sessionData;
isResumedSession = true;
}
} catch {
// Session gone (server restarted, etc.) — fall through to create new
// 404: session was explicitly stopped (via closeAgentTab) but conversation
// files likely still exist on disk. Treat it as cold so we can restore.
// Verify files exist before assuming cold — if queenMessages succeeds with
// content, files are there.
coldRestoreId = historySourceId || storedSessionId;
}
}
if (!liveSession) {
// Reconnect failed — clear stale cached messages from localStorage restore
if (storedSessionId) {
// Reconnect failed — clear stale cached messages from localStorage restore.
// NEVER wipe when: (a) doing a cold restore (we'll restore from disk) or
// (b) handleHistoryOpen already pre-populated messages (alreadyHasMessages).
const alreadyHasMessages = (sessionsRef.current[agentType] || [])[0]?.messages?.length > 0;
if (storedSessionId && !coldRestoreId && !alreadyHasMessages) {
setSessionsByAgent(prev => ({
...prev,
[agentType]: (prev[agentType] || []).map((s, i) =>
@@ -636,8 +728,48 @@ export default function Workspace() {
}));
}
// CRITICAL: Pre-fetch queen messages from the old session directory BEFORE
// creating the new session. When queen_resume_from is set the new session writes
// to the SAME directory, so if we fetch after creation we risk capturing the
// new queen's greeting in the restored history.
// SKIP if messages were already pre-populated by handleHistoryOpen (avoids
// double-fetch and greeting leakage).
let preQueenMsgs: ChatMessage[] = [];
if (coldRestoreId && !alreadyHasMessages) {
try {
const { messages: queenMsgs } = await sessionsApi.queenMessages(coldRestoreId);
// Also pre-fetch worker messages from the old session if a resumable worker exists
const displayNameTemp = formatAgentDisplayName(agentPath);
for (const m of queenMsgs as Message[]) {
const msg = backendMessageToChatMessage(m, agentType, "Queen Bee");
msg.role = "queen";
preQueenMsgs.push(msg);
}
// Also try to grab worker messages while we're here
try {
const { sessions: workerSessions } = await sessionsApi.workerSessions(coldRestoreId);
const resumable = workerSessions.find(s => s.status === "active" || s.status === "paused");
if (resumable) {
const { messages: wMsgs } = await sessionsApi.messages(coldRestoreId, resumable.session_id);
for (const m of wMsgs as Message[]) {
preQueenMsgs.push(backendMessageToChatMessage(m, agentType, displayNameTemp));
}
}
} catch { /* not critical */ }
} catch {
// Not available — will start fresh
}
}
// Suppress intro whenever we are about to restore a previous conversation.
// The user never expects a greeting when reopening a session.
if (coldRestoreId) suppressIntroRef.current.add(agentType);
try {
liveSession = await sessionsApi.create(agentPath);
// Pass coldRestoreId as queenResumeFrom so the backend writes queen
// messages into the ORIGINAL session's directory — all conversation
// history accumulates in one place across server restarts.
liveSession = await sessionsApi.create(agentPath, undefined, undefined, undefined, coldRestoreId ?? undefined);
} catch (loadErr: unknown) {
// 424 = credentials required — open the credentials modal
if (loadErr instanceof ApiError && loadErr.status === 424) {
@@ -678,51 +810,78 @@ export default function Workspace() {
liveSession = body as unknown as LiveSession;
}
}
// If we pre-fetched messages for a cold restore, populate the UI immediately.
// This happens before the SSE connection opens so no greeting can slip through.
if (preQueenMsgs.length > 0) {
preQueenMsgs.sort((a, b) => (a.createdAt ?? 0) - (b.createdAt ?? 0));
setSessionsByAgent(prev => ({
...prev,
[agentType]: (prev[agentType] || []).map((s, i) =>
i === 0 ? { ...s, messages: preQueenMsgs, graphNodes: [] } : s,
),
}));
}
}
// At this point liveSession is guaranteed set — if both reconnect and create
// failed, the throw inside the catch exits the outer try block.
const session = liveSession!;
const displayName = formatAgentDisplayName(session.worker_name || agentPath);
updateAgentState(agentType, { sessionId: session.session_id, displayName });
// NOTE: do NOT set sessionId in agentState yet — that opens the SSE
// connection, and we need suppressIntroRef to be set first (which it is,
// synchronously above). sessionId is set in the final updateAgentState
// at the bottom of this block.
// Update the session label
// Update the session label + backendSessionId. Also set historySourceId
// so the sidebar "already-open" check works even after cold-revive changes
// backendSessionId to a new live session ID.
setSessionsByAgent((prev) => {
const sessions = prev[agentType] || [];
if (!sessions.length) return prev;
return {
...prev,
[agentType]: sessions.map((s, i) =>
i === 0 ? { ...s, label: sessions.length === 1 ? displayName : `${displayName} #${i + 1}`, backendSessionId: session.session_id } : s,
i === 0 ? {
...s,
label: sessions.length === 1 ? displayName : `${displayName} #${i + 1}`,
backendSessionId: session.session_id,
// Preserve existing historySourceId; set it from coldRestoreId if missing
historySourceId: s.historySourceId || coldRestoreId || undefined,
} : s,
),
};
});
// Check worker session status (detects running worker).
// Only restore messages when rejoining an existing backend session.
// Restore messages when rejoining an existing session OR cold-restoring from disk.
let isWorkerRunning = false;
const restoredMsgs: ChatMessage[] = [];
try {
const { sessions: workerSessions } = await sessionsApi.workerSessions(session.session_id);
const resumable = workerSessions.find(
(s) => s.status === "active" || s.status === "paused",
);
isWorkerRunning = resumable?.status === "active";
// For cold-restore, use the old session ID. For live resume, use current session.
const historyId = coldRestoreId ?? (isResumedSession ? session.session_id : undefined);
if (isResumedSession && resumable) {
const { messages } = await sessionsApi.messages(session.session_id, resumable.session_id);
for (const m of messages as Message[]) {
restoredMsgs.push(backendMessageToChatMessage(m, agentType, displayName));
}
}
} catch {
// Worker session listing failed — not critical
}
// Restore queen conversation when rejoining an existing session
if (isResumedSession) {
// For LIVE resume (not cold restore), fetch worker + queen messages now.
// For cold restore they were already pre-fetched above (before create) so we skip to avoid
// double-restoring and to avoid capturing the new greeting.
if (historyId && !coldRestoreId) {
try {
const { messages: queenMsgs } = await sessionsApi.queenMessages(session.session_id);
const { sessions: workerSessions } = await sessionsApi.workerSessions(historyId);
const resumable = workerSessions.find(
(s) => s.status === "active" || s.status === "paused",
);
isWorkerRunning = resumable?.status === "active";
if (resumable) {
const { messages } = await sessionsApi.messages(historyId, resumable.session_id);
for (const m of messages as Message[]) {
restoredMsgs.push(backendMessageToChatMessage(m, agentType, displayName));
}
}
} catch {
// Worker session listing failed — not critical
}
try {
const { messages: queenMsgs } = await sessionsApi.queenMessages(historyId);
for (const m of queenMsgs as Message[]) {
const msg = backendMessageToChatMessage(m, agentType, "Queen Bee");
msg.role = "queen";
@@ -733,7 +892,8 @@ export default function Workspace() {
}
}
// Merge queen + worker messages in chronological order
// Merge messages in chronological order (only for live resume; cold restore
// was already applied above before create).
if (restoredMsgs.length > 0) {
restoredMsgs.sort((a, b) => (a.createdAt ?? 0) - (b.createdAt ?? 0));
setSessionsByAgent((prev) => ({
@@ -744,7 +904,12 @@ export default function Workspace() {
}));
}
// If no messages were actually restored, lift the intro suppression gate
if (restoredMsgs.length === 0 && !coldRestoreId) suppressIntroRef.current.delete(agentType);
updateAgentState(agentType, {
sessionId: session.session_id,
displayName,
ready: true,
loading: false,
queenReady: true,
@@ -1018,6 +1183,9 @@ export default function Workspace() {
const isQueen = streamId === "queen";
if (isQueen) console.log('[QUEEN] handleSSEEvent:', event.type, 'agentType:', agentType);
// Drop queen message content while suppressing the auto-intro after a cold-restore.
// Uses a synchronous ref to avoid race conditions with React state batching.
const suppressQueenMessages = isQueen && suppressIntroRef.current.has(agentType);
const agentDisplayName = agentStates[agentType]?.displayName;
const displayName = isQueen ? "Queen Bee" : (agentDisplayName || undefined);
const role = isQueen ? "queen" as const : "worker" as const;
@@ -1065,6 +1233,8 @@ export default function Workspace() {
case "execution_completed":
if (isQueen) {
// If we were suppressing the intro cycle, this is where it ends — lift the gate.
suppressIntroRef.current.delete(agentType);
updateAgentState(agentType, { isTyping: false });
} else {
// Flush any remaining LLM snapshots before clearing state
@@ -1099,7 +1269,7 @@ export default function Workspace() {
case "llm_text_delta": {
const chatMsg = sseEventToChatMessage(event, agentType, displayName, currentTurn);
if (isQueen) console.log('[QUEEN] chatMsg:', chatMsg?.id, chatMsg?.content?.slice(0, 50), 'turn:', currentTurn);
if (chatMsg) {
if (chatMsg && !suppressQueenMessages) {
if (isQueen) chatMsg.role = role;
upsertChatMessage(agentType, chatMsg);
}
@@ -1509,15 +1679,17 @@ export default function Workspace() {
nodeSpecs: [],
});
// Update session label (tab name) and clear graph nodes for fresh fetch
// Update ONLY the active session's label + graph nodes — never touch
// sessions belonging to a different tab sharing the same agentType key.
// Also clear worker messages so the fresh worker starts with a clean slate.
const activeId = activeSessionRef.current[agentType];
setSessionsByAgent(prev => ({
...prev,
[agentType]: (prev[agentType] || []).map(s => ({
...s,
label: displayName,
graphNodes: [],
messages: s.messages.filter(m => m.role !== "worker"),
})),
[agentType]: (prev[agentType] || []).map(s =>
s.id === activeId || (!activeId && prev[agentType]?.[0]?.id === s.id)
? { ...s, label: displayName, graphNodes: [], messages: s.messages.filter(m => m.role !== "worker") }
: s
),
}));
// Explicitly fetch graph topology for the newly loaded worker
@@ -1607,6 +1779,8 @@ export default function Workspace() {
s.id === activeSession.id ? { ...s, messages: [...s.messages, userMsg] } : s
),
}));
// Clear intro suppression — user is now actively sending messages.
suppressIntroRef.current.delete(activeWorker);
updateAgentState(activeWorker, { isTyping: true });
if (state?.sessionId && state?.ready) {
@@ -1723,9 +1897,9 @@ export default function Workspace() {
: Promise.resolve();
pausePromise
.catch(() => {}) // pause failure shouldn't block kill
.catch(() => { }) // pause failure shouldn't block kill
.then(() => sessionsApi.stop(state.sessionId!))
.catch(() => {}); // fire-and-forget
.catch(() => { }); // fire-and-forget
}
const allTypes = Object.keys(sessionsByAgent).filter(k => (sessionsByAgent[k] || []).length > 0);
@@ -1785,6 +1959,102 @@ export default function Workspace() {
setActiveWorker(tabKey);
}, [sessionsByAgent]);
// Open a history session: switch to its existing tab, or open a new tab.
// Async so we can pre-fetch messages before creating the tab — this gives
// instant visual feedback without waiting for loadAgentForType.
const handleHistoryOpen = useCallback(async (sessionId: string, agentPath?: string | null, agentName?: string | null) => {
// Always refresh the sidebar list so newly-started sessions appear promptly.
setHistorySidebarRefreshKey(k => k + 1);
// Already open as a tab — just switch to it.
for (const [type, sessions] of Object.entries(sessionsByAgent)) {
for (const s of sessions) {
if (s.backendSessionId === sessionId || s.historySourceId === sessionId) {
setActiveWorker(type);
setActiveSessionByAgent(prev => ({ ...prev, [type]: s.id }));
if (s.messages.length > 0) {
suppressIntroRef.current.add(type);
}
return;
}
}
}
// Pre-fetch messages from disk so the tab opens with conversation already shown.
// This happens BEFORE creating the tab so no "new session" empty state is visible.
let prefetchedMessages: ChatMessage[] = [];
try {
const { messages: queenMsgs } = await sessionsApi.queenMessages(sessionId);
for (const m of queenMsgs as Message[]) {
const resolvedType = agentPath || "new-agent";
const msg = backendMessageToChatMessage(m, resolvedType, "Queen Bee");
msg.role = "queen";
prefetchedMessages.push(msg);
}
if (prefetchedMessages.length > 0) {
prefetchedMessages.sort((a, b) => (a.createdAt ?? 0) - (b.createdAt ?? 0));
}
} catch {
// Not available — session will open empty and loadAgentForType will try again
}
const resolvedAgentType = agentPath || "new-agent";
const existingTabCount = Object.keys(sessionsByAgent).filter(
k => baseAgentType(k) === resolvedAgentType && (sessionsByAgent[k] || []).length > 0
).length;
const rawLabel = agentName ||
(agentPath ? agentPath.replace(/\/$/, "").split("/").pop()?.replace(/_/g, " ").replace(/\b\w/g, c => c.toUpperCase()) || agentPath : null) ||
"New Agent";
const label = existingTabCount === 0 ? rawLabel : `${rawLabel} #${existingTabCount + 1}`;
const newSession = createSession(resolvedAgentType, label);
newSession.backendSessionId = sessionId;
newSession.historySourceId = sessionId;
// Pre-populate messages so the chat panel immediately shows the conversation
if (prefetchedMessages.length > 0) {
newSession.messages = prefetchedMessages;
}
const tabKey = existingTabCount === 0 ? resolvedAgentType : `${resolvedAgentType}::${newSession.id}`;
if (tabKey !== resolvedAgentType) newSession.tabKey = tabKey;
// Suppress queen intro BEFORE the tab is created so loadAgentForType
// never sees an unsuppressed window — the user never expects a greeting on reopen.
if (prefetchedMessages.length > 0 || sessionId) {
suppressIntroRef.current.add(tabKey);
}
setSessionsByAgent(prev => ({ ...prev, [tabKey]: [newSession] }));
setActiveSessionByAgent(prev => ({ ...prev, [tabKey]: newSession.id }));
setActiveWorker(tabKey);
}, [sessionsByAgent]);
// Post-mount: open the session from the URL ?session= param via handleHistoryOpen.
// This runs AFTER persisted tabs are hydrated, so dedup works correctly.
// Use a ref guard so it fires exactly once even in React StrictMode.
useEffect(() => {
if (mountedRef.current) return;
mountedRef.current = true;
const sid = initialSessionIdRef.current;
if (!sid) return;
// Fetch agent metadata from the backend so handleHistoryOpen gets the right
// agentPath and agentName (needed to label the tab correctly).
sessionsApi.history().then(r => {
const match = r.sessions.find((s: { session_id: string }) => s.session_id === sid);
handleHistoryOpen(
sid,
match?.agent_path ?? initialAgentRef.current !== "new-agent" ? initialAgentRef.current : null,
match?.agent_name ?? null,
);
}).catch(() => {
// History fetch failed — still open the session with what we know.
handleHistoryOpen(
sid,
initialAgentRef.current !== "new-agent" ? initialAgentRef.current : null,
null,
);
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const activeWorkerLabel = activeAgentState?.displayName || formatAgentDisplayName(baseAgentType(activeWorker));
@@ -1834,9 +2104,20 @@ export default function Workspace() {
{/* Main content area */}
<div className="flex flex-1 min-h-0">
<div className="w-[340px] min-w-[280px] bg-card/30 flex flex-col border-r border-border/30">
{/* ── Persistent history sidebar ──────────────────────────────── */}
<HistorySidebar
onOpen={handleHistoryOpen}
openSessionIds={Object.values(sessionsByAgent).flat().flatMap(s => [s.backendSessionId, s.historySourceId].filter(Boolean)) as string[]}
activeSessionId={agentStates[activeWorker]?.sessionId ?? activeSession?.backendSessionId ?? null}
activeHistorySourceId={activeSession?.historySourceId ?? null}
refreshKey={historySidebarRefreshKey}
/>
{/* ── Pipeline graph + chat ──────────────────────────────────── */}
<div className="w-[300px] min-w-[240px] bg-card/30 flex flex-col border-r border-border/30">
<div className="flex-1 min-h-0">
<AgentGraph
<AgentGraph
nodes={currentGraph.nodes}
title={currentGraph.title}
onNodeClick={(node) => setSelectedNode(prev => prev?.id === node.id ? null : node)}
@@ -1926,7 +2207,7 @@ export default function Workspace() {
<div className="flex items-start gap-3 min-w-0">
<div className="w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0 mt-0.5 bg-[hsl(210,40%,55%)]/15 border border-[hsl(210,40%,55%)]/25">
<span className="text-sm" style={{ color: "hsl(210,40%,55%)" }}>
{{"webhook": "\u26A1", "timer": "\u23F1", "api": "\u2192", "event": "\u223F"}[selectedNode.triggerType || ""] || "\u26A1"}
{{ "webhook": "\u26A1", "timer": "\u23F1", "api": "\u2192", "event": "\u223F" }[selectedNode.triggerType || ""] || "\u26A1"}
</span>
</div>
<div className="min-w-0">