session management and ability to converse from where the chat was left off, fix v1
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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[] }>;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user