"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 (
{open && (
{me?.email || "Signed in"}
{me?.role &&
{me.role}
}
)}
); } /* ---------------- 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 && }
))}
{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 ( ); }; // 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 (
{/* 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) => ( ))}
))}
); } /* ---------------- 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 (