"use client";
/* Forge app shell: topbar, project sidebar, command palette, assistant. */
import { ReactNode, useEffect, useMemo, useRef, useState } from "react";
import { Icon } from "./icons";
import { Avatar, Tile } from "./primitives";
import { PROJECT_NAV, NavLeaf } from "@/lib/data";
import { Markdown } from "./markdown";
/* ---------------- Theme hook ---------------- */
export function useTheme(): [string, (t: string) => void] {
const [theme, setTheme] = useState("light");
useEffect(() => {
const cur = document.documentElement.getAttribute("data-theme") || "light";
setTheme(cur);
}, []);
useEffect(() => {
document.documentElement.setAttribute("data-theme", theme);
}, [theme]);
return [theme, setTheme];
}
/* ---------------- Global rail ---------------- */
function RailBtn({ icon, label, onClick, active }: { icon: string; label: string; onClick?: () => void; active?: boolean }) {
const [hv, setHv] = useState(false);
return (
setHv(true)} onMouseLeave={() => setHv(false)}>
{hv &&
{label}
}
);
}
export function GlobalRail({ theme, setTheme, onCommand, onAssistant, onHome }: { theme: string; setTheme: (t: string) => void; onCommand: () => void; onAssistant: () => void; onHome: () => void }) {
return (
setTheme(theme === "dark" ? "light" : "dark")} />
);
}
/* ---------------- Account menu (avatar + sign out) ---------------- */
function AccountMenu() {
const [open, setOpen] = useState(false);
const [me, setMe] = useState<{ email: string; role: string } | null>(null);
const ref = useRef(null);
useEffect(() => {
let live = true;
import("@/lib/api")
.then(({ api }) => api.me())
.then((m: any) => { if (live) setMe({ email: m.email, role: m.role }); })
.catch(() => {});
return () => { live = false; };
}, []);
useEffect(() => {
if (!open) return;
const h = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); };
window.addEventListener("mousedown", h);
return () => window.removeEventListener("mousedown", h);
}, [open]);
async function signOut() {
const { clearTokens } = await import("@/lib/api");
clearTokens();
window.location.reload();
}
return (
setOpen((o) => !o)} title="Account" aria-label="Account"
style={{ border: "none", background: "none", cursor: "pointer", padding: 0, borderRadius: "50%", display: "flex" }}>
{open && (
{me?.email || "Signed in"}
{me?.role &&
{me.role}
}
Sign out
)}
);
}
/* ---------------- Topbar ---------------- */
export interface Crumb { label: string; onClick?: () => void }
export function Topbar({ crumbs, right, left, onCommand }: { crumbs: Crumb[]; right?: ReactNode; left?: ReactNode; onCommand: () => void }) {
return (
{left}
{crumbs.map((c, i) => (
{i > 0 && }
{c.label}
))}
Search…
⌘K
{right}
);
}
/* ---------------- Project sidebar ---------------- */
export function ProjectSidebar({ project, active, onNav, onBack, refreshKey }: { project: any; active: string; onNav: (id: string) => void; onBack: () => void; refreshKey?: any }) {
const [counts, setCounts] = useState>({});
// api.ts fires this after any create/delete of a counted resource, so the badges
// refresh immediately instead of waiting for a page reload.
const [countsBump, setCountsBump] = useState(0);
useEffect(() => {
const onChange = () => setCountsBump((n) => n + 1);
window.addEventListener("forge:counts-changed", onChange);
return () => window.removeEventListener("forge:counts-changed", onChange);
}, []);
useEffect(() => {
const pid = project?.id;
if (!pid) return;
let live = true;
const refresh = async () => {
// One cheap counts call (COUNT(*) per resource) instead of fetching six full lists
// just to read their `.length`. Re-runs on create/delete via countsBump so badges
// stay in sync. A short poll also keeps the agent-inbox badge current when a workflow
// opens a handoff while the operator is on another screen.
const { api } = await import("@/lib/api");
try {
const c = await api.projectCounts(pid);
if (live) setCounts(c as unknown as Record);
} catch {
if (live) setCounts({});
}
};
refresh();
const timer = window.setInterval(refresh, 15_000);
return () => { live = false; window.clearInterval(timer); };
}, [project?.id, refreshKey, countsBump]);
const renderLeaf = (n: NavLeaf) => {
const on = active === n.id;
const count = n.countKey ? counts[n.countKey] : undefined;
return (
onNav(n.id)} title={n.help || n.label} className={"sidenav-item" + (on ? " active" : "")}
style={{ display: "flex", alignItems: "center", gap: 10, width: "100%", height: 34, padding: "0 10px", marginBottom: 1, borderRadius: 8, border: "none", cursor: "pointer", textAlign: "left", color: on ? "var(--accent)" : "var(--fg-1)", fontSize: 13, fontWeight: on ? 600 : 500, fontFamily: "var(--font-ui)", transition: "color var(--dur-fast)" }}>
{n.label}
{count != null && count > 0 && {count} }
);
};
// Settings is pinned to the bottom (rendered in the footer below), so drop it from the scroll list.
const settingsLeaf = PROJECT_NAV.find((e): e is NavLeaf => "id" in e && e.id === "settings");
return (
{project?.name}
{PROJECT_NAV.map((entry) => {
if ("id" in entry && entry.id === "settings") return null; // pinned to the footer
if ("section" in entry) {
// Static section heading (like the design) - no collapse toggle.
return (
{entry.section}
{entry.items.map(renderLeaf)}
);
}
return renderLeaf(entry);
})}
{/* Settings pinned to the bottom: sits above the scrolling nav (z-index + solid bg + top
shadow) so nav items scroll behind it on short viewports. */}
{settingsLeaf && (
{renderLeaf(settingsLeaf)}
)}
);
}
/* ---------------- Command palette ---------------- */
export function CommandPalette({ open, onClose, onGo, projects }: { open: boolean; onClose: () => void; onGo: (v: any) => void; projects: { id: string; name: string }[] }) {
const [q, setQ] = useState("");
const inputRef = useRef(null);
useEffect(() => {
if (open) { setQ(""); setTimeout(() => inputRef.current?.focus(), 30); }
}, [open]);
const cmds = useMemo(() => {
const first = projects[0]?.id || "p_support";
const list = [
{ sec: "Go to", label: "Home / Dashboard", icon: "dashboard", go: { name: "dashboard" } },
{ sec: "Go to", label: "Workflow Canvas - Support Router", icon: "workflows", go: { name: "project", project: first, screen: "workflow-canvas" } },
{ sec: "Go to", label: "Tool Builder", icon: "tools", go: { name: "project", project: first, screen: "tool-builder" } },
{ sec: "Go to", label: "Agent Config", icon: "agents", go: { name: "project", project: first, screen: "agent-config" } },
{ sec: "Go to", label: "Playground", icon: "playground", go: { name: "project", project: first, screen: "playground" } },
{ sec: "Go to", label: "Traces", icon: "traces", go: { name: "project", project: first, screen: "traces" } },
{ sec: "Go to", label: "Knowledge", icon: "knowledge", go: { name: "project", project: first, screen: "knowledge" } },
{ sec: "Go to", label: "Settings & Secrets", icon: "secret", go: { name: "project", project: first, screen: "settings" } },
{ sec: "Actions", label: "New project…", icon: "plus", go: { name: "onboarding" } },
];
projects.forEach((p) => list.push({ sec: "Projects", label: p.name, icon: "layers", go: { name: "project", project: p.id, screen: "overview" } }));
if (!q) return list;
return list.filter((c) => c.label.toLowerCase().includes(q.toLowerCase()));
}, [q, projects]);
const groups = useMemo(() => { const g: Record = {}; cmds.forEach((c) => (g[c.sec] = g[c.sec] || []).push(c)); return g; }, [cmds]);
if (!open) return null;
return (
e.stopPropagation()}>
setQ(e.target.value)} placeholder="Search projects, workflows, tools, actions…"
style={{ flex: 1, border: "none", outline: "none", background: "none", fontSize: 15, color: "var(--fg-0)", fontFamily: "var(--font-ui)" }} />
esc
{Object.entries(groups).map(([sec, items]) => (
{sec}
{items.map((c, i) => (
{ onGo(c.go); onClose(); }} className="row gap3"
style={{ width: "100%", padding: "8px", border: "none", background: "none", cursor: "pointer", borderRadius: 7, textAlign: "left", color: "var(--fg-1)" }}
onMouseEnter={(e) => (e.currentTarget.style.background = "var(--bg-3)")}
onMouseLeave={(e) => (e.currentTarget.style.background = "none")}>
{c.label}
))}
))}
);
}
/* ---------------- Forge Assistant ---------------- */
interface AsstMsg { role: "user" | "assistant"; content: string; thinking?: string; thinkSecs?: number }
interface AsstStep { name: string; result?: string; turn: number }
interface AsstTodo { content?: string; status?: string; [k: string]: any }
// Friendly labels for the inline "current step" line (the Steps drawer keeps raw names).
const TOOL_LABELS: Record = {
write_todos: "Planning the steps",
list_resources: "Reviewing the project",
describe_workflow: "Reading the workflow",
list_node_types: "Checking available nodes",
get_node_schema: "Checking node options",
list_middleware_types: "Checking middleware",
read_file: "Reading the platform guide",
create_agent_preset: "Creating an agent",
create_builtin_tool: "Adding a tool",
create_rest_tool: "Adding a REST tool",
create_auth_provider: "Adding an auth provider",
add_qa_pair: "Adding a Q&A pair",
add_knowledge_text: "Adding knowledge",
create_grounded_workflow: "Building the workflow",
create_intent_router_workflow: "Building the workflow",
create_custom_workflow: "Building the workflow",
add_human_review: "Adding a human-approval step",
test_workflow: "Testing the workflow",
evaluate_build: "Reviewing the result",
delete_workflow: "Removing a workflow",
};
const prettyTool = (name: string) => TOOL_LABELS[name] || name.replace(/_/g, " ");
function newThreadId() {
return `panel-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}
export function AssistantPanel({ open, onClose, project, onMutate }: { open: boolean; onClose: () => void; project?: { id: string; name: string } | null; onMutate?: () => void }) {
const [msgs, setMsgs] = useState([]);
const [input, setInput] = useState("");
const [streaming, setStreaming] = useState("");
// GPT-style thinking: while the agent works through intermediate narration, the text
// streams as dim auto-scrolling lines under a "Thinking" label; on finish it collapses
// to "Thought for Xs" and only the final segment stays as the answer bubble.
const [liveThink, setLiveThink] = useState(null);
const [openThought, setOpenThought] = useState(null);
// Tool/plan activity lives in the collapsible Steps drawer above the composer
// (not as chips in the transcript).
const [steps, setSteps] = useState([]);
const [currentTool, setCurrentTool] = useState(null);
const [stepsOpen, setStepsOpen] = useState(false);
const [expandedStep, setExpandedStep] = useState(null);
const [todos, setTodos] = useState([]);
const [busy, setBusy] = useState(false);
const [pendingApproval, setPendingApproval] = useState(null); // human-readable prompt
const turnRef = useRef(0);
// Server-side conversation: the backend checkpointer holds the thread (history,
// plan, files), so each turn sends ONLY the new message under this thread id.
const threadRef = useRef(newThreadId());
const scrollRef = useRef(null);
const thinkRef = useRef(null);
const taRef = useRef(null);
useEffect(() => { scrollRef.current?.scrollTo({ top: 1e9, behavior: "smooth" }); }, [msgs, streaming, currentTool, pendingApproval, liveThink !== null]);
// Auto-grow the composer up to ~6 lines, then scroll inside it.
useEffect(() => {
const ta = taRef.current;
if (!ta) return;
ta.style.height = "auto";
ta.style.height = `${Math.min(ta.scrollHeight, 140)}px`;
}, [input]);
// The thinking ticker auto-scrolls its own little window as text streams.
useEffect(() => { if (thinkRef.current) thinkRef.current.scrollTop = thinkRef.current.scrollHeight; }, [liveThink]);
// New project = new conversation thread.
useEffect(() => { threadRef.current = newThreadId(); setMsgs([]); setSteps([]); setTodos([]); setPendingApproval(null); setLiveThink(null); }, [project?.id]);
function describeInterrupt(data: any): string {
const flat = (x: any): any[] => (Array.isArray(x) ? x.flatMap(flat) : [x]);
for (const item of flat(data?.interrupts ?? data ?? [])) {
const v = item && typeof item === "object" && "value" in item ? item.value : item;
const reqs = v?.action_requests || (v?.action_request ? [v.action_request] : v?.action ? [v] : null);
if (reqs) {
return reqs.map((r: any) => r.description || `${r.action || r.name || "action"}(${JSON.stringify(r.args || {}).slice(0, 100)})`).join("; ");
}
if (v?.prompt) return String(v.prompt);
}
return "The assistant wants to perform a sensitive action.";
}
async function streamTurn(body: Record, url: string) {
if (!project) return;
setStreaming(""); setLiveThink(null); setBusy(true); setPendingApproval(null);
const turn = ++turnRef.current;
const turnStart = Date.now();
let mutated = false; let interruptPrompt: string | null = null; let acted = false;
// Segmentation: each AI message in the agent loop is one segment (the backend tags
// tokens with the message id; a tool call also closes the segment). Everything
// before the LAST segment is "thinking"; the last segment is the answer.
let cur = ""; // current segment
let segId: string | null = null;
const done: string[] = []; // completed (thinking) segments
let thinking = false; // becomes true on first tool call / segment change
const render = () => {
if (thinking) {
setStreaming("");
setLiveThink([...done, cur].filter(Boolean).join("\n\n"));
} else {
setStreaming(cur);
}
};
const closeSegment = () => {
if (cur.trim()) done.push(cur.trim());
cur = "";
};
const activateThinking = () => { thinking = true; };
try {
const { openSSE } = await import("@/lib/api");
await openSSE(url, (f) => {
if (f.event === "messages" && f.data?.content) {
const id = f.data.id || null;
if (segId && id && id !== segId) { closeSegment(); activateThinking(); }
if (id) segId = id;
cur += f.data.content;
render();
}
else if (f.event === "tool" && f.data?.name) {
acted = true;
closeSegment(); activateThinking(); render();
setCurrentTool(f.data.name);
setSteps((s) => [...s, { name: f.data.name, result: f.data.result, turn }]);
}
else if (f.event === "todos" && Array.isArray(f.data?.todos)) { acted = true; setTodos(f.data.todos); }
else if (f.event === "interrupt") { interruptPrompt = describeInterrupt(f.data); }
else if (f.event === "done") { mutated = (f.data?.mutated || []).length > 0; }
else if (f.event === "error") { cur += `\n⚠ ${f.data?.message || "assistant error"}`; render(); }
}, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
} catch (e: any) {
cur += `\n⚠ ${e.message || e}`;
} finally {
const answer = cur.trim();
const thought = thinking ? done.join("\n\n") : "";
const secs = Math.max(1, Math.round((Date.now() - turnStart) / 1000));
if (answer || thought || acted) {
setMsgs((m) => [...m, {
role: "assistant",
content: answer || (interruptPrompt ? "(paused for your approval)" : "(done - see Steps for details)"),
...(thought ? { thinking: thought, thinkSecs: secs } : {}),
}]);
}
setStreaming(""); setLiveThink(null); setCurrentTool(null); setBusy(false);
setPendingApproval(interruptPrompt);
if (mutated) onMutate?.();
}
}
async function send(text: string) {
const q = text.trim();
if (!q || busy || !project) return;
setMsgs((m) => [...m, { role: "user", content: q }]); setInput("");
const { api } = await import("@/lib/api");
await streamTurn({ message: q, thread_id: threadRef.current }, api.assistantStreamUrl(project.id));
}
async function decide(decision: "approve" | "reject") {
if (!project || busy) return;
setMsgs((m) => [...m, { role: "user", content: decision === "approve" ? "✓ Approved" : "✕ Rejected" }]);
const { api } = await import("@/lib/api");
await streamTurn({ thread_id: threadRef.current, decision }, api.assistantResumeUrl(project.id));
}
function resetChat() {
threadRef.current = newThreadId();
setMsgs([]); setSteps([]); setTodos([]); setPendingApproval(null); setStreaming(""); setCurrentTool(null); setStepsOpen(false);
}
const suggestions = ["How does my workflow work?", "Build a grounded support workflow", "What's in this project?"];
if (!open) return null;
return (
);
}