pi-feats 0.1.5 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +48 -5
  2. package/extensions/api-server/README.md +21 -1
  3. package/extensions/api-server/application-runtime.ts +65 -4
  4. package/extensions/api-server/application-store.ts +5 -5
  5. package/extensions/api-server/index.ts +6 -2
  6. package/extensions/api-server/profile-store.ts +81 -34
  7. package/extensions/api-server/server.ts +179 -42
  8. package/extensions/cli-resources.ts +125 -94
  9. package/extensions/cli-tui-mode.ts +7 -0
  10. package/extensions/context-memory/index.ts +6 -1
  11. package/extensions/guardrails/index.ts +2 -1
  12. package/extensions/lib/application-context.ts +2 -0
  13. package/extensions/lib/cli-tui-mode.ts +72 -0
  14. package/extensions/lib/context-memory.ts +6 -1
  15. package/extensions/lib/package-resources.ts +58 -0
  16. package/extensions/lib/pi-command.ts +11 -0
  17. package/extensions/lib/profile-sandbox.ts +11 -3
  18. package/extensions/lib/remote-hosts.ts +7 -5
  19. package/extensions/lib/workflow-evaluator.ts +113 -0
  20. package/extensions/lib/workflow-harness.ts +409 -43
  21. package/extensions/pi-console-webui/app/[section]/page.tsx +1 -1
  22. package/extensions/pi-console-webui/app/globals.css +3 -0
  23. package/extensions/pi-console-webui/components/application-handler-editor.tsx +1 -1
  24. package/extensions/pi-console-webui/components/application-sessions.tsx +2 -2
  25. package/extensions/pi-console-webui/components/application-settings.tsx +12 -6
  26. package/extensions/pi-console-webui/components/chat-workspace.tsx +17 -7
  27. package/extensions/pi-console-webui/components/console-page.tsx +1 -0
  28. package/extensions/pi-console-webui/components/console-state.tsx +1 -1
  29. package/extensions/pi-console-webui/components/console.tsx +26 -9
  30. package/extensions/pi-console-webui/components/guardrails-panel.tsx +2 -2
  31. package/extensions/pi-console-webui/components/profile-model-form.tsx +2 -2
  32. package/extensions/pi-console-webui/components/pulse-resources.tsx +4 -3
  33. package/extensions/pi-console-webui/components/sequential-workflow-config.tsx +32 -0
  34. package/extensions/pi-console-webui/components/skill-resources.tsx +4 -3
  35. package/extensions/pi-console-webui/components/ui/input.tsx +1 -1
  36. package/extensions/pi-console-webui/index.ts +2 -1
  37. package/extensions/pi-console-webui/lib/admin-models.ts +6 -4
  38. package/extensions/pi-console-webui/lib/page-metadata.ts +1 -0
  39. package/extensions/profile-ssh.ts +2 -2
  40. package/extensions/profiles.ts +17 -9
  41. package/extensions/pulse/index.ts +35 -17
  42. package/extensions/pulse/store.ts +29 -7
  43. package/extensions/sequential-workflow.ts +121 -79
  44. package/extensions/skill-sources/store.ts +29 -10
  45. package/package.json +19 -9
  46. package/scripts/build-console-webui.mjs +7 -0
  47. package/scripts/console-webui-cache.mjs +79 -0
  48. package/scripts/ensure-console-build.mjs +43 -0
  49. package/skills/sequential-workflow/SKILL.md +4 -3
  50. package/themes/legacy-dark.json +85 -0
@@ -34,6 +34,6 @@ export function ApplicationHandlerEditor({ name, embedded = false }: { name: str
34
34
  const renameFile = async () => { if (!renamePath || !validName(renameName)) return toast("Use a valid handler name.", "error"); const path = renamePath.includes("/") ? `${renamePath.slice(0, renamePath.lastIndexOf("/") + 1)}${renameName}.ts` : `${renameName}.ts`; try { const data = await api(`${base}/handler?path=${encodeURIComponent(renamePath)}`, { method: "PATCH", body: JSON.stringify({ newPath: path }) }); setFiles((items) => items.map((item) => item === renamePath ? data.path : item).sort()); setSelected(data.path); setRenamePath(undefined); toast("Handler renamed and Application references updated."); } catch (error) { toast((error as Error).message, "error"); } };
35
35
  const deleteFile = async (path: string) => { if (!confirm(`Delete ${path}? This cannot be undone.`)) return; try { await fetch(`/api/pi/${base}/handler?path=${encodeURIComponent(path)}`, { method: "DELETE" }).then(async (response) => { if (!response.ok) throw new Error((await response.json().catch(() => ({}))).error?.message ?? "Unable to delete handler"); }); setFiles((items) => items.filter((item) => item !== path)); if (selected === path) { const next = files.find((item) => item !== path) ?? ""; setSelected(next); setContent(""); } toast("Handler deleted."); } catch (error) { toast((error as Error).message, "error"); } };
36
36
  const groups = new Map<string, string[]>(); for (const file of files) { const index = file.lastIndexOf("/"); const folder = index < 0 ? "handlers" : `handlers/${file.slice(0, index)}`; groups.set(folder, [...(groups.get(folder) ?? []), file]); }
37
- return <div className="space-y-4">{!embedded && <div className="flex items-center gap-3"><Link href="/applications?context=admin" className="rounded-lg p-2 text-zinc-500 hover:bg-zinc-100" title="Back to Applications"><ArrowLeft size={18}/></Link><div><h2 className="text-xl font-semibold">Edit handlers: {name}</h2><p className="text-sm text-zinc-500">Changes are loaded on the next Application request.</p></div></div>}<div className="grid min-h-[650px] gap-4 lg:grid-cols-[320px_minmax(0,1fr)]"><Card className="overflow-auto"><div className="flex items-center justify-between gap-2"><CardTitle>Files</CardTitle><Button className="bg-zinc-100 text-zinc-700 hover:bg-zinc-200" title="Add handler" onClick={() => { setHandlerType("inbound"); setNewName(""); setCreateDialog(true); }}><Plus size={17}/></Button></div><div className="mt-4 space-y-4">{[...groups].map(([folder, paths]) => <div key={folder}><p className="mb-1 text-xs font-semibold text-zinc-500">{folder}</p>{paths.map((path) => <div key={path} className={`flex items-center rounded-md ${selected === path ? "bg-blue-50 text-blue-800" : "hover:bg-zinc-100"}`}><button onClick={() => setSelected(path)} className="flex min-w-0 flex-1 items-center gap-2 px-2 py-2 text-left text-sm"><FileCode2 size={15}/><span className="truncate" title={path.split("/").at(-1)}>{path.split("/").at(-1)}</span></button><div className="relative"><button title={`Actions for ${path}`} className="rounded p-2 text-zinc-500 hover:bg-zinc-200 hover:text-zinc-900" onClick={() => setMenuPath((current) => current === path ? undefined : path)}><MoreHorizontal size={16}/></button>{menuPath === path && <div className="absolute right-0 z-20 mt-1 w-32 rounded-lg border border-zinc-200 bg-white p-1 shadow-lg"><button className="w-full rounded px-3 py-2 text-left text-sm hover:bg-zinc-100" onClick={() => { setRenamePath(path); setRenameName(fileName(path)); setMenuPath(undefined); }}>Rename</button><button className="w-full rounded px-3 py-2 text-left text-sm text-red-700 hover:bg-red-50" onClick={() => { setMenuPath(undefined); void deleteFile(path); }}>Delete</button></div>}</div></div>)}</div>)}{!loading && !files.length && <p className="text-sm text-zinc-500">No handler files found.</p>}</div></Card><Card className="flex min-w-0 flex-col"><div className="flex items-center justify-between gap-3"><div><CardTitle>{selected || "Select a handler"}</CardTitle><p className="text-sm text-zinc-500">TypeScript source</p></div><div className="flex gap-2"><Button className="bg-zinc-100 text-zinc-700 hover:bg-zinc-200" title="Restore editor to the handler template without saving" disabled={!selected} onClick={() => setContent(templateFor(typeForPath(selected)))}><RotateCcw size={17}/></Button><Button className="!bg-[#2bbb77] hover:!bg-[#249b63]" title="Save handler" disabled={!selected} onClick={save}><Save size={17}/></Button></div></div>{selected && <div className="mt-4 min-h-0 flex-1 overflow-auto rounded-lg border border-zinc-200"><CodeMirror value={content} height="560px" extensions={[javascript({ typescript: true })]} onChange={setContent}/></div>}</Card></div><Card><div className="mb-4"><CardTitle>Test handler</CardTitle><p className="text-sm text-zinc-500">Runs the current editor content without saving it.</p></div><div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] lg:items-center"><div className="overflow-hidden rounded-lg border border-zinc-200"><p className="border-b border-zinc-200 bg-zinc-50 px-3 py-2 text-xs font-semibold text-zinc-600">INPUT / PAYLOAD</p><CodeMirror value={payload} height="260px" extensions={[json()]} onChange={setPayload}/></div><Button className="self-center !bg-[#2bbb77] hover:!bg-[#249b63]" title="Run handler test" disabled={!selected || testing} onClick={test}><Play size={18}/></Button><div className="overflow-hidden rounded-lg border border-zinc-200"><p className="border-b border-zinc-200 bg-zinc-50 px-3 py-2 text-xs font-semibold text-zinc-600">OUTPUT</p><CodeMirror value={output} height="260px" extensions={[json()]} editable={false}/></div></div></Card><Card><CardTitle>Logs</CardTitle><pre className="handler-test-logs mt-4 min-h-28">{logs || "Run a test to view handler logs."}</pre></Card>{createDialog && <Dialog title="New handler" close={() => setCreateDialog(false)}><p className="text-sm text-zinc-500">Choose a type and provide a unique handler name.</p><Input className="mt-4" value={newName} onChange={(event) => setNewName(event.target.value)} placeholder="e.g. whatsapp-inbound" autoFocus/><div className="mt-4 space-y-2">{handlerTypes.map((type) => <button key={type.value} onClick={() => setHandlerType(type.value)} className={`w-full rounded-lg border p-3 text-left ${handlerType === type.value ? "border-blue-500 bg-blue-50" : "border-zinc-200 hover:bg-zinc-50"}`}><span className="block text-sm font-medium">{type.label}</span><span className="mt-1 block text-xs text-zinc-500">{type.description}</span></button>)}</div><div className="mt-5 flex justify-end gap-2"><Button className="bg-zinc-700 hover:bg-zinc-600" onClick={() => setCreateDialog(false)}>Cancel</Button><Button onClick={create}>Create handler</Button></div></Dialog>}{renamePath && <Dialog title="Rename handler" close={() => setRenamePath(undefined)}><p className="text-sm text-zinc-500">{renamePath.includes("/") ? `Folder: ${renamePath.slice(0, renamePath.lastIndexOf("/"))}` : "Folder: handlers"}</p><Input className="mt-4" value={renameName} onChange={(event) => setRenameName(event.target.value)} autoFocus/><div className="mt-5 flex justify-end gap-2"><Button className="bg-zinc-700 hover:bg-zinc-600" onClick={() => setRenamePath(undefined)}>Cancel</Button><Button onClick={renameFile}>Rename handler</Button></div></Dialog>}</div>;
37
+ return <div className="space-y-4">{!embedded && <div className="flex items-center gap-3"><Link href="/applications?context=admin" className="rounded-lg p-2 text-zinc-500 hover:bg-zinc-100" title="Back to Applications"><ArrowLeft size={18}/></Link><div><h2 className="text-xl font-semibold">Edit handlers: {name}</h2><p className="text-sm text-zinc-500">Changes are loaded on the next Application request.</p></div></div>}<div className="grid min-h-[650px] gap-4 lg:grid-cols-[320px_minmax(0,1fr)]"><Card className="overflow-auto"><div className="flex items-center justify-between gap-2"><CardTitle>Files</CardTitle><Button className="bg-zinc-100 text-zinc-700 hover:bg-zinc-200" title="Add handler" onClick={() => { setHandlerType("inbound"); setNewName(""); setCreateDialog(true); }}><Plus size={17}/></Button></div><div className="mt-4 space-y-4">{[...groups].map(([folder, paths]) => <div key={folder}><p className="mb-1 text-xs font-semibold text-zinc-500">{folder}</p>{paths.map((path) => <div key={path} className={`flex items-center rounded-md ${selected === path ? "bg-blue-50 text-blue-800" : "hover:bg-zinc-100"}`}><button onClick={() => setSelected(path)} className="flex min-w-0 flex-1 items-center gap-2 px-2 py-2 text-left text-sm"><FileCode2 size={15}/><span className="truncate" title={path.split("/").at(-1)}>{path.split("/").at(-1)}</span></button><div className="relative"><button title={`Actions for ${path}`} className="rounded p-2 text-zinc-500 hover:bg-zinc-200 hover:text-zinc-900" onClick={() => setMenuPath((current) => current === path ? undefined : path)}><MoreHorizontal size={16}/></button>{menuPath === path && <div className="absolute right-0 z-20 mt-1 w-32 rounded-lg border border-zinc-200 bg-white p-1 shadow-lg"><button className="w-full rounded px-3 py-2 text-left text-sm hover:bg-zinc-100" onClick={() => { setRenamePath(path); setRenameName(fileName(path)); setMenuPath(undefined); }}>Rename</button><button className="w-full rounded px-3 py-2 text-left text-sm text-red-700 hover:bg-red-50" onClick={() => { setMenuPath(undefined); void deleteFile(path); }}>Delete</button></div>}</div></div>)}</div>)}{!loading && !files.length && <p className="text-sm text-zinc-500">No handler files found.</p>}</div></Card><Card className="flex min-w-0 flex-col"><div className="flex items-center justify-between gap-3"><div><CardTitle>{selected || "Select a handler"}</CardTitle><p className="text-sm text-zinc-500">TypeScript source</p></div><div className="flex gap-2"><Button className="bg-zinc-100 text-zinc-700 hover:bg-zinc-200" title="Restore editor to the handler template without saving" disabled={!selected} onClick={() => setContent(templateFor(typeForPath(selected)))}><RotateCcw size={17}/></Button><Button className="!bg-[#2bbb77] hover:!bg-[#249b63]" title="Save handler" disabled={!selected} onClick={save}><Save size={17}/></Button></div></div>{selected && <div className="mt-4 min-h-0 flex-1 overflow-auto rounded-lg border border-zinc-200"><CodeMirror value={content} height="560px" extensions={[javascript({ typescript: true })]} onChange={setContent}/></div>}</Card></div><Card><div className="mb-4"><CardTitle>Test handler</CardTitle><p className="text-sm text-zinc-500">Runs the current editor content without saving it.</p></div><div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] lg:items-center"><div className="overflow-hidden rounded-lg border border-zinc-200"><p className="border-b border-zinc-200 bg-zinc-50 px-3 py-2 text-xs font-semibold text-zinc-600">INPUT / PAYLOAD</p><CodeMirror value={payload} height="260px" extensions={[json()]} onChange={setPayload}/></div><Button className="self-center !bg-[#2bbb77] hover:!bg-[#249b63]" title="Run handler test" disabled={!selected || testing} onClick={test}><Play size={18}/></Button><div className="overflow-hidden rounded-lg border border-zinc-200"><p className="border-b border-zinc-200 bg-zinc-50 px-3 py-2 text-xs font-semibold text-zinc-600">OUTPUT</p><CodeMirror value={output} height="260px" extensions={[json()]} editable={false}/></div></div></Card><Card><CardTitle>Logs</CardTitle><pre className="handler-test-logs mt-4 min-h-28">{logs || "Run a test to view handler logs."}</pre></Card>{createDialog && <Dialog title="New handler" close={() => setCreateDialog(false)}><p className="text-sm text-zinc-500">Choose a type and provide a unique handler name.</p><label className="mt-4 block text-sm">Handler name<Input className="mt-1" value={newName} onChange={(event) => setNewName(event.target.value)} placeholder="e.g. whatsapp-inbound" autoFocus/></label><p className="mt-4 text-sm font-semibold">Handler type</p><div className="mt-2 space-y-2">{handlerTypes.map((type) => <button key={type.value} onClick={() => setHandlerType(type.value)} className={`w-full rounded-lg border p-3 text-left ${handlerType === type.value ? "border-blue-500 bg-blue-50" : "border-zinc-200 hover:bg-zinc-50"}`}><span className="block text-sm font-normal">{type.label}</span><span className="mt-1 block text-xs text-zinc-500">{type.description}</span></button>)}</div><div className="mt-5 flex justify-end gap-2"><Button className="bg-zinc-700 hover:bg-zinc-600" onClick={() => setCreateDialog(false)}>Cancel</Button><Button onClick={create}>Create handler</Button></div></Dialog>}{renamePath && <Dialog title="Rename handler" close={() => setRenamePath(undefined)}><p className="text-sm text-zinc-500">{renamePath.includes("/") ? `Folder: ${renamePath.slice(0, renamePath.lastIndexOf("/"))}` : "Folder: handlers"}</p><label className="mt-4 block text-sm">Handler name<Input className="mt-1" value={renameName} onChange={(event) => setRenameName(event.target.value)} autoFocus/></label><div className="mt-5 flex justify-end gap-2"><Button className="bg-zinc-700 hover:bg-zinc-600" onClick={() => setRenamePath(undefined)}>Cancel</Button><Button onClick={renameFile}>Rename handler</Button></div></Dialog>}</div>;
38
38
  }
39
39
  function Dialog({ title, close, children }: { title: string; close: () => void; children: React.ReactNode }) { return <div className="fixed inset-0 z-50 grid place-items-center bg-black/30 p-4" role="dialog" aria-modal="true"><div className="w-full max-w-md rounded-xl bg-white p-6 shadow-2xl"><div className="flex items-start justify-between gap-3"><h3 className="text-lg font-semibold">{title}</h3><button title="Close" className="rounded-lg p-1 text-zinc-500 hover:bg-zinc-100" onClick={close}><X size={18}/></button></div>{children}</div></div>; }
@@ -5,7 +5,7 @@ import { Button } from "@/components/ui/button";
5
5
  import { Card, CardTitle } from "@/components/ui/card";
6
6
  import { Input } from "@/components/ui/input";
7
7
  import { useToast } from "@/components/toast";
8
- type Session = { application: string; profile: string; sessionPrefix: string; sessionId: string; messageCount: number; updatedAt: string; lastIdentityKey: string | null };
8
+ type Session = { application: string; profile: string; sessionPrefix: string; sessionId: string; sessionType: "stateful" | "stateless"; messageCount: number; updatedAt: string; lastIdentityKey: string | null };
9
9
  type Filters = { profile: string; application: string; identityKey: string; prefix: string; session: string };
10
10
  const api = async (path: string, init?: RequestInit) => { const response = await fetch(`/api/pi/${path}`, { ...init, headers: { "content-type": "application/json", ...init?.headers } }); if (!response.ok) throw new Error((await response.json().catch(() => ({}))).error?.message ?? "Request failed"); return response.status === 204 ? undefined : response.json(); };
11
- export function ApplicationSessions({ profile }: { profile: string }) { const { toast } = useToast(); const [sessions, setSessions] = useState<Session[]>([]), [busy, setBusy] = useState<string>(), [filters, setFilters] = useState<Filters>({ profile, application: "", identityKey: "", prefix: "", session: "" }); const load = async (current = filters) => { const query = new URLSearchParams(Object.entries(current).filter(([, value]) => value.trim())); setSessions((await api(`application-sessions?${query}`)).sessions); }; useEffect(() => { setFilters((current) => ({ ...current, profile })); }, [profile]); useEffect(() => { void load().catch((cause) => toast(cause.message, "error")); }, [filters]); const update = (key: keyof Filters, value: string) => setFilters((current) => ({ ...current, [key]: value })); const rollover = async (session: Session) => { try { setBusy(session.sessionId); await api(`applications/${session.application}/session-rollover`, { method: "POST", body: JSON.stringify({ profile: session.profile, sessionPrefix: session.sessionPrefix }) }); await load(); toast("Session rolled over.", "success"); } catch (cause) { toast((cause as Error).message, "error"); } finally { setBusy(undefined); } }; const remove = async (session: Session) => { if (!confirm(`Remove active session '${session.sessionId}'? Its Pi session history will be kept.`)) return; try { setBusy(session.sessionId); await api(`application-sessions/${encodeURIComponent(session.sessionId)}?${new URLSearchParams({ application: session.application, profile: session.profile })}`, { method: "DELETE" }); await load(); toast("Active session removed.", "success"); } catch (cause) { toast((cause as Error).message, "error"); } finally { setBusy(undefined); } }; return <Card className="md:col-span-3"><div className="flex items-center justify-between gap-3"><CardTitle>Active Application Sessions</CardTitle><Button className="bg-zinc-100 text-zinc-700 hover:bg-zinc-200" title="Refresh sessions" onClick={() => void load().catch((cause) => toast(cause.message, "error"))}><RotateCw size={16}/></Button></div><div className="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-5">{([['profile','Profile'],['application','Application'],['identityKey','Identity key'],['prefix','Prefix'],['session','Session']] as Array<[keyof Filters,string]>).map(([key,label]) => <label key={key} className="text-xs font-medium text-zinc-600">{label}<Input className="mt-1 h-9" value={filters[key]} onChange={(event) => update(key, event.target.value)}/></label>)}</div><div className="mt-4 overflow-x-auto"><table className="w-full text-left text-sm"><thead className="border-b text-zinc-500"><tr><th className="p-3">Application</th><th className="p-3">Identity key</th><th className="p-3">Profile</th><th className="p-3">Prefix</th><th className="p-3">Session</th><th className="p-3">Messages</th><th className="p-3"><span className="sr-only">Actions</span></th></tr></thead><tbody>{sessions.map((session) => <tr className="border-b border-zinc-100" key={session.sessionId}><td className="p-3">{session.application}</td><td className="p-3">{session.lastIdentityKey ?? "—"}</td><td className="p-3">{session.profile}</td><td className="p-3 font-mono text-xs">{session.sessionPrefix}</td><td className="p-3 font-mono text-xs">{session.sessionId}</td><td className="p-3">{session.messageCount}</td><td className="p-3"><div className="flex gap-2"><Button className="bg-zinc-100 text-zinc-700 hover:bg-zinc-200" title="Rollover session" disabled={busy === session.sessionId} onClick={() => rollover(session)}>{busy === session.sessionId ? <Loader2 className="animate-spin" size={16}/> : <RotateCw size={16}/>}</Button><Button className="bg-zinc-100 text-zinc-700 hover:bg-red-50 hover:text-red-700" title="Remove active session" disabled={busy === session.sessionId} onClick={() => void remove(session)}><Trash2 size={16}/></Button></div></td></tr>)}{!sessions.length && <tr><td className="p-5 text-zinc-500" colSpan={7}>No active Application sessions match these filters.</td></tr>}</tbody></table></div></Card>; }
11
+ export function ApplicationSessions({ profile }: { profile: string }) { const { toast } = useToast(); const [sessions, setSessions] = useState<Session[]>([]), [busy, setBusy] = useState<string>(), [filters, setFilters] = useState<Filters>({ profile, application: "", identityKey: "", prefix: "", session: "" }); const load = async (current = filters) => { const query = new URLSearchParams(Object.entries(current).filter(([, value]) => value.trim())); setSessions((await api(`application-sessions?${query}`)).sessions); }; useEffect(() => { setFilters((current) => ({ ...current, profile })); }, [profile]); useEffect(() => { void load().catch((cause) => toast(cause.message, "error")); }, [filters]); const update = (key: keyof Filters, value: string) => setFilters((current) => ({ ...current, [key]: value })); const rollover = async (session: Session) => { try { setBusy(session.sessionId); await api(`applications/${session.application}/session-rollover`, { method: "POST", body: JSON.stringify({ profile: session.profile, sessionPrefix: session.sessionPrefix }) }); await load(); toast("Session rolled over.", "success"); } catch (cause) { toast((cause as Error).message, "error"); } finally { setBusy(undefined); } }; const remove = async (session: Session) => { if (!confirm(`Remove active session '${session.sessionId}'? Its Pi session history will be kept.`)) return; try { setBusy(session.sessionId); await api(`application-sessions/${encodeURIComponent(session.sessionId)}?${new URLSearchParams({ application: session.application, profile: session.profile })}`, { method: "DELETE" }); await load(); toast("Active session removed.", "success"); } catch (cause) { toast((cause as Error).message, "error"); } finally { setBusy(undefined); } }; return <Card className="md:col-span-3"><div className="flex items-center justify-between gap-3"><CardTitle>Active Application Sessions</CardTitle><Button className="bg-zinc-100 text-zinc-700 hover:bg-zinc-200" title="Refresh sessions" onClick={() => void load().catch((cause) => toast(cause.message, "error"))}><RotateCw size={16}/></Button></div><div className="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-5">{([['profile','Profile'],['application','Application'],['identityKey','Identity key'],['prefix','Prefix'],['session','Session']] as Array<[keyof Filters,string]>).map(([key,label]) => <label key={key} className="text-xs font-medium text-zinc-600">{label}<Input className="mt-1 h-9" value={filters[key]} onChange={(event) => update(key, event.target.value)}/></label>)}</div><div className="mt-4 overflow-x-auto"><table className="w-full text-left text-sm"><thead className="border-b text-zinc-500"><tr><th className="p-3">Application</th><th className="p-3">Identity key</th><th className="p-3">Profile</th><th className="p-3">Prefix</th><th className="p-3">Session</th><th className="p-3">Type</th><th className="p-3">Count</th><th className="p-3"><span className="sr-only">Actions</span></th></tr></thead><tbody>{sessions.map((session) => <tr className="border-b border-zinc-100" key={session.sessionId}><td className="p-3">{session.application}</td><td className="p-3">{session.lastIdentityKey ?? "—"}</td><td className="p-3">{session.profile}</td><td className="p-3 font-mono text-xs">{session.sessionPrefix}</td><td className="p-3 font-mono text-xs">{session.sessionId}</td><td className="p-3"><span className="rounded-full px-2 py-1 text-xs font-medium" style={session.sessionType === "stateless" ? { backgroundColor: "#ebfcff", color: "#0a6b51" } : { backgroundColor: "#eff6ff", color: "#1e40af" }}>{session.sessionType}</span></td><td className="p-3">{session.messageCount} {session.sessionType === "stateless" ? "requests" : "messages"}</td><td className="p-3"><div className="flex gap-2">{session.sessionType === "stateful" && <Button className="bg-zinc-100 text-zinc-700 hover:bg-zinc-200" title="Rollover session" disabled={busy === session.sessionId} onClick={() => rollover(session)}>{busy === session.sessionId ? <Loader2 className="animate-spin" size={16}/> : <RotateCw size={16}/>}</Button>}<Button className="bg-zinc-100 text-zinc-700 hover:bg-red-50 hover:text-red-700" title="Remove active session" disabled={busy === session.sessionId} onClick={() => void remove(session)}><Trash2 size={16}/></Button></div></td></tr>)}{!sessions.length && <tr><td className="p-5 text-zinc-500" colSpan={8}>No active Application sessions match these filters.</td></tr>}</tbody></table></div></Card>; }
@@ -1,14 +1,14 @@
1
1
  "use client";
2
2
  import { useEffect, useState } from "react";
3
3
  import { useRouter } from "next/navigation";
4
- import { ArrowDown, ArrowUp, Check, Copy, Loader2, Plus, Trash2 } from "lucide-react";
4
+ import { ArrowDown, ArrowUp, Check, Copy, Loader2, Plus, Trash2, X } from "lucide-react";
5
5
  import { Button } from "@/components/ui/button";
6
6
  import { Card, CardTitle } from "@/components/ui/card";
7
7
  import { Input } from "@/components/ui/input";
8
8
  import { Switch } from "@/components/ui/switch";
9
9
  import { useToast } from "@/components/toast";
10
10
 
11
- type Settings = { inboundHandler: string; outboundHandler?: string; transformHandlers?: string[]; messageCoalescing?: { enabled?: boolean; silenceDebounceSeconds?: number } };
11
+ type Settings = { inboundHandler: string; outboundHandler?: string; transformHandlers?: string[]; directMode?: { enabled?: boolean }; messageCoalescing?: { enabled?: boolean; silenceDebounceSeconds?: number } };
12
12
  type App = { name: string; slug: string; enabled: boolean; responseMode: "ack" | "result"; defaultProfile: string | null; routingPolicy: "default_as_fallback" | "drop"; settings: Settings };
13
13
  type HandlerOptions = { inbound: string[]; outbound: string[]; transform: string[] };
14
14
  const api = async (path: string, init?: RequestInit) => { const response = await fetch(`/api/pi/${path}`, { ...init, headers: { "content-type": "application/json", ...init?.headers } }); if (!response.ok) throw new Error((await response.json().catch(() => ({}))).error?.message ?? "Request failed"); return response.json(); };
@@ -19,9 +19,9 @@ export function ApplicationSettings({ slug }: { slug: string }) {
19
19
  const [app, setApp] = useState<App>();
20
20
  const [profiles, setProfiles] = useState<string[]>([]);
21
21
  const [handlers, setHandlers] = useState<HandlerOptions>({ inbound: [], outbound: [], transform: [] });
22
- const [saving, setSaving] = useState(false), [error, setError] = useState(""), [copyMenu, setCopyMenu] = useState(false);
22
+ const [saving, setSaving] = useState(false), [error, setError] = useState(""), [copyMenu, setCopyMenu] = useState(false), [examplesOpen, setExamplesOpen] = useState(false);
23
23
  useEffect(() => {
24
- if (isNew) { api("profiles").then((profileData) => { setProfiles(profileData.profiles.map((item: { name: string }) => item.name)); setApp({ name: "", slug: "", enabled: true, responseMode: "ack", defaultProfile: null, routingPolicy: "default_as_fallback", settings: { inboundHandler: "inbound", transformHandlers: [] } }); setHandlers({ inbound: ["inbound"], outbound: [], transform: [] }); }).catch((cause) => setError(cause.message)); return; }
24
+ if (isNew) { api("profiles").then((profileData) => { setProfiles(profileData.profiles.map((item: { name: string }) => item.name)); setApp({ name: "", slug: "", enabled: true, responseMode: "ack", defaultProfile: null, routingPolicy: "default_as_fallback", settings: { inboundHandler: "inbound", transformHandlers: [], directMode: { enabled: true } } }); setHandlers({ inbound: ["inbound"], outbound: [], transform: [] }); }).catch((cause) => setError(cause.message)); return; }
25
25
  Promise.all([api(`applications/${slug}`), api("profiles"), api(`applications/${slug}/handlers`)]).then(([application, profileData, handlerData]) => { setApp(application.application); setProfiles(profileData.profiles.map((item: { name: string }) => item.name)); const files = handlerData.files as string[]; const regular = files.filter((file) => !file.startsWith("transforms/")).map(handlerName); setHandlers({ inbound: regular, outbound: regular, transform: files.filter((file) => file.startsWith("transforms/")).map(handlerName) }); }).catch((cause) => setError(cause.message));
26
26
  }, [slug, isNew]);
27
27
  if (!app) return <Card>{error || "Loading Application settings…"}</Card>;
@@ -33,13 +33,17 @@ export function ApplicationSettings({ slug }: { slug: string }) {
33
33
  const moveTransform = (from: number, to: number) => { if (to < 0 || to >= transformHandlers.length) return; const next = [...transformHandlers]; [next[from], next[to]] = [next[to], next[from]]; updateSettings({ transformHandlers: next }); };
34
34
  const setTransform = (index: number, value: string) => { const next = [...transformHandlers]; next[index] = value; updateSettings({ transformHandlers: next }); };
35
35
  const save = async () => { try { setSaving(true); setError(""); if (isNew) { const result = await api("applications", { method: "POST", body: JSON.stringify(app) }); toast("Application created."); router.replace(`/applications/${encodeURIComponent(result.application.slug)}?context=admin&tab=settings`); return; } const result = await api(`applications/${slug}`, { method: "PUT", body: JSON.stringify(app) }); setApp(result.application); toast("Application saved."); } catch (cause) { const message = (cause as Error).message; setError(message); toast(message, "error"); } finally { setSaving(false); } };
36
- const copy = async (format: "url" | "curl") => { const url = `${window.location.origin}/api/message/app/${app.slug}`; try { await navigator.clipboard.writeText(format === "url" ? url : `curl --request POST '${url}' \\\n --header 'Content-Type: application/json' \\\n --data '{"message":"Hello"}'`); toast(format === "url" ? "Application URL copied." : "Example cURL copied."); } catch { toast("Unable to copy to the clipboard.", "error"); } };
36
+ const endpoint = typeof window === "undefined" ? `/api/message/app/${app.slug}` : `${window.location.origin}/api/message/app/${app.slug}`;
37
+ const copy = async (text: string, message = "Example cURL copied.") => { try { await navigator.clipboard.writeText(text); toast(message); } catch { toast("Unable to copy to the clipboard.", "error"); } };
38
+ const examples = [{ title: "Conventional Application request", description: "Runs the configured transform, inbound, and outbound handlers.", command: `curl --request POST '${endpoint}' \\\n --header 'Content-Type: application/json' \\\n --data '{"message":"Hello"}'` }, { title: "Direct Mode — stateful", description: "Identity routing selects the profile and Pi keeps the conversation in its Application session. sessionType defaults to stateful.", command: `curl --request POST '${endpoint}' \\\n --header 'Content-Type: application/json' \\\n --data '{"identityKey":"customer@example.com","message":"Where is my order?"}'` }, { title: "Direct Mode — stateful, explicit session", description: "Provide sessionId when the integration needs to create or adopt a specific persistent Application session.", command: `curl --request POST '${endpoint}' \\
39
+ --header 'Content-Type: application/json' \\
40
+ --data '{"identityKey":"customer@example.com","sessionId":"support_customer_2026-03-25-01","message":"Where is my order?"}'` }, { title: "Direct Mode — stateless, empty context", description: "Uses the Application route but does not reuse a Pi transcript. No history means an empty conversation context.", command: `curl --request POST '${endpoint}' \\\n --header 'Content-Type: application/json' \\\n --data '{"identityKey":"customer@example.com","sessionType":"stateless","message":"Hello"}'` }, { title: "Direct Mode — stateless, client history", description: "The caller owns history and must send every turn that Pi should consider.", command: `curl --request POST '${endpoint}' \\\n --header 'Content-Type: application/json' \\\n --data '{"identityKey":"customer@example.com","sessionType":"stateless","history":[{"role":"user","content":"Hello"},{"role":"assistant","content":"How can I help?"}],"message":"Continue."}'` }];
37
41
  const renderHandlerOptions = (available: string[], current?: string, blank = "Select a handler") => <>{!current && <option value="">{blank}</option>}{current && !available.includes(current) && <option value={current}>{current} (missing)</option>}{available.map((name) => <option key={name} value={name}>{name}</option>)}</>;
38
42
 
39
43
  return <div className="space-y-5">
40
44
  {error && <p className="rounded-lg bg-red-50 p-3 text-sm text-red-700">{error}</p>}
41
45
  <Card className="grid gap-4 md:grid-cols-2">
42
- <div className="flex items-center justify-between gap-3 md:col-span-2"><CardTitle>{isNew ? "Create Application" : "General"}</CardTitle><div className="flex gap-2"><div className="relative"><Button className="bg-zinc-100 text-zinc-700 hover:bg-zinc-200" title="Copy public endpoint" aria-expanded={copyMenu} onClick={() => setCopyMenu((open) => !open)}><Copy size={17}/></Button>{copyMenu && <div className="absolute right-0 z-20 mt-1 w-48 rounded-lg border border-zinc-200 bg-white p-1 shadow-lg"><button className="w-full rounded px-3 py-2 text-left text-sm text-zinc-700 hover:bg-zinc-100" onClick={() => { setCopyMenu(false); void copy("url"); }}>Simply Application URL</button><button className="w-full rounded px-3 py-2 text-left text-sm text-zinc-700 hover:bg-zinc-100" onClick={() => { setCopyMenu(false); void copy("curl"); }}>Example cURL</button></div>}</div><Button className="!bg-[#2bbb77] hover:!bg-[#249b63]" title="Save Application" disabled={saving} onClick={save}>{saving ? <Loader2 className="animate-spin" size={17}/> : <Check size={17}/>}</Button></div></div>
46
+ <div className="flex items-center justify-between gap-3 md:col-span-2"><CardTitle>{isNew ? "Create Application" : "General"}</CardTitle><div className="flex gap-2"><div className="relative"><Button className="bg-zinc-100 text-zinc-700 hover:bg-zinc-200" title="Copy public endpoint" aria-expanded={copyMenu} disabled={!app.slug} onClick={() => setCopyMenu((open) => !open)}><Copy size={17}/></Button>{copyMenu && <div className="absolute right-0 z-20 mt-1 w-48 rounded-lg border border-zinc-200 bg-white p-1 shadow-lg"><button className="w-full rounded px-3 py-2 text-left text-sm text-zinc-700 hover:bg-zinc-100" onClick={() => { setCopyMenu(false); void copy(endpoint, "Application URL copied."); }}>Simple Application URL</button><button className="w-full rounded px-3 py-2 text-left text-sm text-zinc-700 hover:bg-zinc-100" onClick={() => { setCopyMenu(false); setExamplesOpen(true); }}>Example cURLs</button></div>}</div><Button className="!bg-[#2bbb77] hover:!bg-[#249b63]" title="Save Application" disabled={saving} onClick={save}>{saving ? <Loader2 className="animate-spin" size={17}/> : <Check size={17}/>}</Button></div></div>
43
47
  <label className="text-sm font-medium">Name<Input className="mt-1" value={app.name} onChange={(event) => update({ name: event.target.value })}/></label>
44
48
  <label className="text-sm font-medium">Slug{isNew ? <Input className="mt-1 font-mono" value={app.slug} placeholder="my-application" onChange={(event) => update({ slug: event.target.value.toLowerCase() })}/> : <div className="mt-1 rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-2 font-mono text-sm">{app.slug}</div>}</label>
45
49
  <label className="text-sm font-medium">Public endpoint<div className="mt-1 rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-2 font-mono text-sm">/api/message/app/{app.slug || "…"}</div></label>
@@ -54,11 +58,13 @@ export function ApplicationSettings({ slug }: { slug: string }) {
54
58
  </Card>
55
59
  <Card className="space-y-4">
56
60
  <div><CardTitle>Pipeline</CardTitle><p className="mt-1 text-sm text-zinc-500">{isNew ? "Save the Application before creating and selecting custom handlers." : "Handlers are managed in the Handlers tab. Transform order is execution order."}</p></div>
61
+ <div className="flex items-start justify-between gap-4 rounded-lg border border-zinc-200 p-3"><div><p className="text-sm font-medium">Direct Mode</p><p className="mt-1 text-xs text-zinc-500">A root-level <code>identityKey</code> and <code>message</code> bypass Transform, Inbound, and Outbound handlers while Identity Mappings still select the profile. Optional <code>sessionId</code> and transient <code>contextMemory</code> are supported. Ack mode requires <code>responseWebhookUrl</code>. Existing handler-based requests continue normally.</p></div><Switch className="mt-0.5 shrink-0" checked={settings.directMode?.enabled !== false} onCheckedChange={(enabled) => updateSettings({ directMode: { enabled } })}/></div>
57
62
  <label className="block text-sm font-medium">Inbound handler<select className="mt-1 w-full rounded-lg border border-zinc-200 px-3 py-2" value={settings.inboundHandler} onChange={(event) => updateSettings({ inboundHandler: event.target.value })}>{renderHandlerOptions(handlers.inbound, settings.inboundHandler)}</select></label>
58
63
  <label className="block text-sm font-medium">Outbound handler<select className="mt-1 w-full rounded-lg border border-zinc-200 px-3 py-2" value={settings.outboundHandler ?? ""} onChange={(event) => updateSettings({ outboundHandler: event.target.value || undefined })}>{renderHandlerOptions(handlers.outbound, settings.outboundHandler, "None")}</select></label>
59
64
  <div><div className="flex items-center justify-between gap-3"><div><p className="text-sm font-medium">Transform handlers</p><p className="text-xs text-zinc-500">They run from top to bottom before the inbound handler.</p></div><Button className="bg-zinc-100 text-zinc-700 hover:bg-zinc-200" title="Add transform handler" disabled={handlers.transform.length === 0 || transformHandlers.length >= handlers.transform.length} onClick={() => { const next = handlers.transform.find((name) => !transformHandlers.includes(name)); if (next) updateSettings({ transformHandlers: [...transformHandlers, next] }); }}><Plus size={16}/></Button></div>
60
65
  <div className="mt-3 space-y-2">{transformHandlers.map((handler, index) => <div className="flex gap-2" key={`${handler}-${index}`}><select className="min-w-0 flex-1 rounded-lg border border-zinc-200 px-3 py-2" value={handler} onChange={(event) => setTransform(index, event.target.value)}>{renderHandlerOptions(handlers.transform.filter((name) => name === handler || !transformHandlers.includes(name)), handler)}</select><Button className="bg-zinc-100 text-zinc-700 hover:bg-zinc-200" title="Move transform up" disabled={index === 0} onClick={() => moveTransform(index, index - 1)}><ArrowUp size={16}/></Button><Button className="bg-zinc-100 text-zinc-700 hover:bg-zinc-200" title="Move transform down" disabled={index === transformHandlers.length - 1} onClick={() => moveTransform(index, index + 1)}><ArrowDown size={16}/></Button><Button className="bg-zinc-100 text-zinc-700 hover:bg-red-50 hover:text-red-700" title="Remove transform handler" onClick={() => updateSettings({ transformHandlers: transformHandlers.filter((_, item) => item !== index) })}><Trash2 size={16}/></Button></div>)}{handlers.transform.length === 0 && <p className="rounded-lg border border-dashed border-zinc-200 p-3 text-sm text-zinc-500">No transform handlers are registered. Create one in the Handlers tab.</p>}{handlers.transform.length > 0 && transformHandlers.length === 0 && <p className="text-sm text-zinc-500">No transforms configured.</p>}</div>
61
66
  </div>
62
67
  </Card>
68
+ {examplesOpen && <div className="fixed inset-0 z-50 grid place-items-center bg-black/30 p-4" role="dialog" aria-modal="true" aria-labelledby="curl-examples-title"><div className="max-h-[85vh] w-full max-w-3xl overflow-auto rounded-xl bg-white p-6 shadow-2xl"><div className="flex items-start justify-between gap-4"><div><h2 id="curl-examples-title" className="text-lg font-semibold">Example cURLs</h2><p className="mt-1 text-sm text-zinc-500">Choose the request style that matches your integration.</p></div><button className="rounded p-2 text-zinc-500 hover:bg-zinc-100" title="Close" onClick={() => setExamplesOpen(false)}><X size={18}/></button></div><div className="mt-5 space-y-5">{examples.map((example) => <section key={example.title} className="rounded-lg border border-zinc-200 p-4"><div className="flex items-start justify-between gap-3"><div><h3 className="font-medium">{example.title}</h3><p className="mt-1 text-sm text-zinc-500">{example.description}</p></div><Button className="shrink-0 bg-zinc-100 text-zinc-700 hover:bg-zinc-200" title={`Copy ${example.title}`} onClick={() => void copy(example.command)}><Copy size={16}/></Button></div><pre className="mt-3 overflow-x-auto rounded-lg p-3 text-xs" style={{ backgroundColor: "#f4f4f5", color: "#18181b" }}><code style={{ color: "inherit" }}>{example.command}</code></pre></section>)}</div><div className="mt-6 flex justify-end"><Button className="bg-zinc-700 hover:bg-zinc-600" onClick={() => setExamplesOpen(false)}>Close</Button></div></div></div>}
63
69
  </div>;
64
70
  }
@@ -9,24 +9,30 @@ import { Card } from "@/components/ui/card";
9
9
  import { Input } from "@/components/ui/input";
10
10
  import { useToast } from "@/components/toast";
11
11
 
12
- type Session = { id: string; name?: string; preview: string; updatedAt: string; messageCount: number };
12
+ type Session = { id: string; name?: string; preview: string; updatedAt: string; messageCount: number; kind?: "conversation" | "pulse-execution" | "pulse-only"; pulse?: { name?: string; type?: "cron" | "heartbeat"; status?: "running" | "success" | "error"; source?: "execution" | "injected-result" } };
13
13
  type Block = { type: "text" | "thinking"; text: string };
14
14
  type Entry = { id: string; type: string; timestamp: string; role?: string; content?: Block[]; tools?: Array<{ name: string; arguments: unknown }>; toolName?: string; isError?: boolean; summary?: string };
15
+ type Activity = { id: string; kind: "tool" | "thinking" | "agent"; status: "started" | "running" | "completed" | "failed" | "interrupted"; name: string; summary: string; parentToolCallId?: string };
15
16
  const call = async (path: string, init?: RequestInit) => { const response = await fetch(`/api/pi/${path}`, { ...init, headers: { "content-type": "application/json", ...init?.headers } }); if (!response.ok) { const body = await response.json().catch(() => ({})); throw new Error(body.error?.message ?? body.error ?? "Request failed"); } return response; };
16
17
  const ago = (date: string) => { const minutes = Math.max(0, Math.round((Date.now() - new Date(date).getTime()) / 60000)); return minutes < 1 ? "now" : minutes < 60 ? `${minutes}m` : minutes < 1440 ? `${Math.round(minutes / 60)}h` : `${Math.round(minutes / 1440)}d`; };
17
18
  const text = (entry: Entry) => entry.content?.filter((block) => block.type === "text").map((block) => block.text).join("\n") ?? "";
19
+ const mergeConversationEntries = (current: Entry[], persisted: Entry[]) => {
20
+ const pending = current.filter((entry) => entry.id.startsWith("local-") && !persisted.some((saved) => saved.role === entry.role && text(saved) === text(entry)));
21
+ return [...persisted, ...pending];
22
+ };
18
23
 
19
24
  export function ChatWorkspace({ profile }: { profile: string }) {
20
25
  const searchParams = useSearchParams(); const router = useRouter(); const { toast } = useToast();
21
26
  // Profile selection updates the route asynchronously. Never resolve a
22
27
  // session from the previous URL against the newly selected profile.
23
28
  const requestedSession = (searchParams.get("profile") ?? "default") === profile ? searchParams.get("session") : null;
24
- const [sessions, setSessions] = useState<Session[]>([]), [newMenu, setNewMenu] = useState(false), [applicationDialog, setApplicationDialog] = useState(false), [creatingApplicationSession, setCreatingApplicationSession] = useState(false), [applications, setApplications] = useState<Array<{ slug: string; name: string }>>([]), [application, setApplication] = useState(""), [identityKey, setIdentityKey] = useState(""), [selected, setSelected] = useState<string>(), [entries, setEntries] = useState<Entry[]>([]), [hasMore, setHasMore] = useState(false), [nextBefore, setNextBefore] = useState<string | null>(null), [loadingOlder, setLoadingOlder] = useState(false), [copiedSessionId, setCopiedSessionId] = useState(false), [editingName, setEditingName] = useState(false), [sessionName, setSessionName] = useState(""), [query, setQuery] = useState(""), [debouncedQuery, setDebouncedQuery] = useState(""), [message, setMessage] = useState(""), [streaming, setStreaming] = useState(""), [busy, setBusy] = useState(false), [error, setError] = useState(""), [sidebar, setSidebar] = useState(true);
29
+ const [sessions, setSessions] = useState<Session[]>([]), [showPulseExecutions, setShowPulseExecutions] = useState(false), [newMenu, setNewMenu] = useState(false), [applicationDialog, setApplicationDialog] = useState(false), [creatingApplicationSession, setCreatingApplicationSession] = useState(false), [applications, setApplications] = useState<Array<{ slug: string; name: string }>>([]), [application, setApplication] = useState(""), [identityKey, setIdentityKey] = useState(""), [selected, setSelected] = useState<string>(), [entries, setEntries] = useState<Entry[]>([]), [hasMore, setHasMore] = useState(false), [nextBefore, setNextBefore] = useState<string | null>(null), [loadingOlder, setLoadingOlder] = useState(false), [copiedSessionId, setCopiedSessionId] = useState(false), [editingName, setEditingName] = useState(false), [sessionName, setSessionName] = useState(""), [query, setQuery] = useState(""), [debouncedQuery, setDebouncedQuery] = useState(""), [message, setMessage] = useState(""), [streaming, setStreaming] = useState(""), [activities, setActivities] = useState<Activity[]>([]), [busy, setBusy] = useState(false), [error, setError] = useState(""), [sidebar, setSidebar] = useState(true);
25
30
  const abortRef = useRef<AbortController | undefined>(undefined); const bottom = useRef<HTMLDivElement>(null); const conversation = useRef<HTMLDivElement>(null); const loadingSession = useRef<string | undefined>(undefined);
26
- const loadSessions = async () => { const data = await (await call(`profiles/${profile}/sessions`)).json() as { sessions: Session[] }; setSessions(data.sessions); return data.sessions; };
27
- const loadConversation = async (id: string) => { const key = `${profile}\u0000${id}`; loadingSession.current = key; setSelected(id); setEditingName(false); setEntries([]); setHasMore(false); setNextBefore(null); setStreaming(""); const data = await (await call(`profiles/${profile}/sessions/${id}?limit=16`)).json() as { entries: Entry[]; hasMore: boolean; nextBefore: string | null }; if (loadingSession.current !== key) return; setEntries(data.entries); setHasMore(data.hasMore); setNextBefore(data.nextBefore); requestAnimationFrame(() => bottom.current?.scrollIntoView({ behavior: "auto" })); };
31
+ const loadSessions = async () => { const data = await (await call(`profiles/${profile}/sessions${showPulseExecutions ? "?includePulseSessions=true" : ""}`)).json() as { sessions: Session[] }; setSessions(data.sessions); return data.sessions; };
32
+ const loadConversation = async (id: string, replace = true) => { const key = `${profile}\u0000${id}`; loadingSession.current = key; setSelected(id); setEditingName(false); if (replace) { setEntries([]); setHasMore(false); setNextBefore(null); } setStreaming(""); const data = await (await call(`profiles/${profile}/sessions/${id}?limit=16`)).json() as { entries: Entry[]; hasMore: boolean; nextBefore: string | null }; if (loadingSession.current !== key) return; setEntries((current) => replace ? data.entries : mergeConversationEntries(current, data.entries)); setHasMore(data.hasMore); setNextBefore(data.nextBefore); requestAnimationFrame(() => bottom.current?.scrollIntoView({ behavior: "auto" })); };
28
33
  const loadOlder = async () => { if (!selected || !hasMore || !nextBefore || loadingOlder) return; const id = selected, key = `${profile}\u0000${id}`, element = conversation.current, height = element?.scrollHeight ?? 0, top = element?.scrollTop ?? 0; setLoadingOlder(true); try { const data = await (await call(`profiles/${profile}/sessions/${id}?limit=16&before=${encodeURIComponent(nextBefore)}`)).json() as { entries: Entry[]; hasMore: boolean; nextBefore: string | null }; if (loadingSession.current !== key) return; setEntries((current) => [...data.entries, ...current]); setHasMore(data.hasMore); setNextBefore(data.nextBefore); requestAnimationFrame(() => { if (element) element.scrollTop = top + element.scrollHeight - height; }); } catch (cause) { setError((cause as Error).message); } finally { setLoadingOlder(false); } };
29
34
  useEffect(() => { loadingSession.current = undefined; setSelected(undefined); setEntries([]); setHasMore(false); setNextBefore(null); setStreaming(""); setError(""); loadSessions().catch((cause) => setError(cause.message)); }, [profile]);
35
+ useEffect(() => { loadSessions().catch((cause) => setError(cause.message)); }, [showPulseExecutions]);
30
36
  useEffect(() => { if (requestedSession) loadConversation(requestedSession).catch((cause) => setError(cause.message)); }, [profile, requestedSession]);
31
37
  useEffect(() => { if (busy || streaming) bottom.current?.scrollIntoView({ behavior: "auto" }); }, [streaming, busy]);
32
38
  useEffect(() => { const timer = setTimeout(() => setDebouncedQuery(query), 600); return () => clearTimeout(timer); }, [query]);
@@ -39,7 +45,11 @@ export function ChatWorkspace({ profile }: { profile: string }) {
39
45
  async function createApplicationSession() { if (!application || !identityKey.trim()) { toast("Select an Application and enter an Identity key.", "error"); return; } try { setCreatingApplicationSession(true); const created = await (await call(`profiles/${profile}/application-sessions`, { method: "POST", body: JSON.stringify({ application, identityKey: identityKey.trim() }) })).json() as { id: string }; setApplicationDialog(false); await loadSessions(); openConversation(created.id); } catch (cause) { toast((cause as Error).message, "error"); } finally { setCreatingApplicationSession(false); } }
40
46
  async function remove() { if (!selected || busy || !window.confirm("Delete this conversation permanently?")) return; try { await call(`profiles/${profile}/sessions/${selected}`, { method: "DELETE" }); setSelected(undefined); setEntries([]); setStreaming(""); setError(""); await loadSessions(); } catch (cause) { setError((cause as Error).message); } }
41
47
  async function rename() { if (!selected || !sessionName.trim()) return; try { await call(`profiles/${profile}/sessions/${selected}`, { method: "PATCH", body: JSON.stringify({ name: sessionName.trim() }) }); setEditingName(false); await loadSessions(); } catch (cause) { setError((cause as Error).message); } }
42
- async function send(event: React.FormEvent) { event.preventDefault(); if (!message.trim() || busy) return; let id = selected; try { setError(""); if (!id) { const created = await (await call(`profiles/${profile}/sessions`, { method: "POST", body: JSON.stringify({}) })).json() as { id: string }; id = created.id; setSelected(id); } const userEntry: Entry = { id: `local-${Date.now()}`, type: "message", role: "user", timestamp: new Date().toISOString(), content: [{ type: "text", text: message }] }; setEntries((current) => [...current, userEntry]); const prompt = message; setMessage(""); setBusy(true); setStreaming(""); const controller = new AbortController(); abortRef.current = controller; const response = await call(`profile/${profile}/api/sessions/${id}/chat/stream`, { method: "POST", body: JSON.stringify({ message: prompt }), signal: controller.signal }); const reader = response.body?.getReader(), decoder = new TextDecoder(); let buffer = "", complete = false; while (reader && !complete) { const part = await reader.read(); if (part.done) break; buffer += decoder.decode(part.value, { stream: true }); const events = buffer.split("\n\n"); buffer = events.pop() ?? ""; for (const item of events) { const data = item.split("\n").find((line) => line.startsWith("data: "))?.slice(6); if (!data) continue; const payload = JSON.parse(data); if (typeof payload.error === "string") throw new Error(payload.error); if (payload.text) setStreaming((current) => current + payload.text); if (typeof payload.response === "string") { setStreaming(payload.response); complete = true; break; } } } await loadConversation(id); await loadSessions(); } catch (cause) { if ((cause as Error).name !== "AbortError") setError((cause as Error).message); } finally { abortRef.current = undefined; setBusy(false); setStreaming(""); } }
43
- return <Card className="h-[calc(100vh-215px)] min-h-[520px] p-0"><div className="flex h-full min-h-0 overflow-hidden rounded-xl"><aside className={`${sidebar ? "w-96" : "w-0"} flex shrink-0 flex-col overflow-hidden border-r border-zinc-200 bg-zinc-50 transition-all`}><div className="flex h-full w-96 min-h-0 flex-col p-4"><div className="mb-4 flex items-center justify-between"><h2 className="font-semibold">Conversations</h2><div className="relative"><Button title="New session" onClick={() => setNewMenu((current) => !current)}><Plus size={17}/></Button>{newMenu && <div className="absolute right-0 z-20 mt-1 w-48 rounded-lg border border-zinc-200 bg-white p-1 shadow-lg"><button className="w-full rounded px-3 py-2 text-left text-sm hover:bg-zinc-100" onClick={create}>Regular session</button><button className="w-full rounded px-3 py-2 text-left text-sm hover:bg-zinc-100" onClick={() => void openApplicationDialog()}>Application session</button></div>}</div></div><div className="relative mb-3"><Search className="absolute left-3 top-2.5 text-zinc-400" size={16}/><Input className="pl-9" value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Search sessions"/></div><div className="min-h-0 flex-1 space-y-1 overflow-y-auto pr-1">{filtered.map((session) => <button key={session.id} onClick={() => { setCopiedSessionId(false); openConversation(session.id); }} className={`w-full rounded-lg p-3 text-left hover:bg-white ${selected === session.id ? "bg-white shadow-sm ring-1 ring-blue-200" : ""}`}><div className="flex items-center gap-2"><MessageSquare size={15} className="text-blue-600"/><span className="min-w-0 flex-1 truncate text-sm font-medium">{session.name || session.preview || "New conversation"}</span><span className="text-xs text-zinc-400">{ago(session.updatedAt)}</span></div><p className="mt-1 truncate text-xs text-zinc-500">{session.preview || `${session.messageCount} messages`}</p><p className="mt-1 truncate font-mono text-[10px] text-zinc-400">{session.id}</p></button>)}{filtered.length === 0 && <p className="p-4 text-center text-sm text-zinc-500">No sessions found.</p>}</div></div></aside><section className="flex min-h-0 min-w-0 flex-1 flex-col"><header className="flex items-center gap-3 border-b border-zinc-200 px-5 py-4"><Button className="bg-zinc-100 text-zinc-700 hover:bg-zinc-200" onClick={() => setSidebar((current) => !current)}><PanelLeft size={17}/></Button><div className="min-w-0"><div className="flex items-center gap-1">{!editingName && <h2 className="truncate font-semibold">{selected ? activeSession?.name || activeSession?.preview || "Untitled session" : "New conversation"}</h2>}{selected && !editingName && <button className="shrink-0 rounded p-1 text-zinc-400 hover:bg-zinc-100 hover:text-zinc-900" title="Rename session" onClick={() => { setSessionName(activeSession?.name || activeSession?.preview || ""); setEditingName(true); }}><Pencil size={14}/></button>}</div>{selected && editingName && <div className="mt-2 flex items-center gap-1"><Input autoFocus className="h-8 min-w-64" value={sessionName} onChange={(event) => setSessionName(event.target.value)} onKeyDown={(event) => { if (event.key === "Enter") void rename(); if (event.key === "Escape") setEditingName(false); }}/><button className="rounded p-1.5 text-emerald-700 hover:bg-emerald-50" title="Save session name" onClick={() => void rename()}><Check size={16}/></button><button className="rounded p-1.5 text-zinc-500 hover:bg-zinc-100" title="Cancel" onClick={() => setEditingName(false)}><X size={16}/></button></div>}<p className="text-xs text-zinc-500">Profile: {profile}</p>{selected && <div className="mt-1 flex items-center gap-1 text-xs text-zinc-400"><span className="truncate font-mono">{selected}</span><button className="shrink-0 rounded p-1 hover:bg-zinc-100" title="Copy session ID" onClick={() => void copySessionId()}>{copiedSessionId ? <Check size={13}/> : <Copy size={13}/>}</button></div>}</div>{selected && <Button className="ml-auto bg-zinc-100 text-zinc-700 hover:bg-red-50 hover:text-red-700" title="Delete conversation" onClick={remove} disabled={busy}><Trash2 size={17}/></Button>}</header><div ref={conversation} onScroll={(event) => { if (event.currentTarget.scrollTop < 80) void loadOlder(); }} className="min-h-0 flex-1 space-y-5 overflow-y-auto bg-white px-5 py-6">{loadingOlder && <div className="py-2 text-center text-xs text-zinc-400">Loading older messages…</div>}{hasMore && !loadingOlder && <div className="py-2 text-center text-xs text-zinc-400">Scroll up to load older messages</div>}{!selected && entries.length === 0 && <div className="mx-auto mt-28 max-w-md text-center"><div className="mx-auto mb-4 w-fit rounded-2xl bg-blue-50 p-4 text-blue-600"><Bot size={30}/></div><h3 className="text-xl font-semibold">Start a conversation</h3><p className="mt-2 text-sm text-zinc-500">Write a message below or choose a previous session.</p></div>}{entries.filter((entry) => entry.role !== "toolResult").map((entry) => <Message key={entry.id} entry={entry}/>) }{busy && <Message entry={{ id: "streaming", type: "message", role: "assistant", timestamp: new Date().toISOString(), content: [{ type: "text", text: streaming }] }} streaming/>}<div ref={bottom}/></div>{error && <p className="border-t border-red-200 bg-red-50 px-5 py-2 text-sm text-red-700">{error}</p>}<form onSubmit={send} className="border-t border-zinc-200 bg-zinc-50 p-4"><div className="flex gap-3"><textarea className="min-h-14 flex-1 resize-none rounded-xl border border-zinc-200 bg-white p-3 text-sm outline-none focus:border-blue-500" value={message} onChange={(event) => setMessage(event.target.value)} onKeyDown={(event) => { if (event.key === "Enter" && !event.shiftKey && !event.altKey && !event.nativeEvent.isComposing) { event.preventDefault(); event.currentTarget.form?.requestSubmit(); } }} placeholder="Message Pi… (Enter to send, Shift+Enter for a new line)"/>{busy ? <Button type="button" className="bg-zinc-700 hover:bg-zinc-600" title="Stop generation" onClick={() => abortRef.current?.abort()}><CircleStop size={17}/></Button> : <Button className="bg-slate-600 hover:bg-slate-500" title="Send message"><SendHorizontal size={17}/></Button>}</div></form></section></div>{applicationDialog && <div className="fixed inset-0 z-50 grid place-items-center bg-black/30 p-4" role="dialog" aria-modal="true"><Card className="w-full max-w-md"><h3 className="text-lg font-semibold">Application session</h3><p className="mt-1 text-sm text-zinc-500">Creates a manual Console session with Context Memory for the selected Application and identity.</p><div className="mt-4 grid gap-4"><label className="grid gap-1.5 text-sm font-medium">Application<select className="rounded-md border border-zinc-200 px-3 py-2 font-normal" value={application} onChange={(event) => setApplication(event.target.value)}><option value="">Select an Application</option>{applications.map((item) => <option key={item.slug} value={item.slug}>{item.name}</option>)}</select></label><label className="grid gap-1.5 text-sm font-medium">Identity key<Input value={identityKey} onChange={(event) => setIdentityKey(event.target.value)} placeholder="e.g. person@example.com"/></label><div className="flex justify-end gap-2"><Button className="bg-zinc-700 hover:bg-zinc-600" onClick={() => setApplicationDialog(false)}>Cancel</Button><Button disabled={creatingApplicationSession} onClick={() => void createApplicationSession()}>{creatingApplicationSession ? <Loader2 className="animate-spin" size={17}/> : "Create session"}</Button></div></div></Card></div>}</Card>;
48
+ async function send(event: React.FormEvent) { event.preventDefault(); if (!message.trim() || busy) return; let id = selected; try { setError(""); if (!id) { const created = await (await call(`profiles/${profile}/sessions`, { method: "POST", body: JSON.stringify({}) })).json() as { id: string }; id = created.id; setSelected(id); } const localEntryId = `local-${Date.now()}`; const userEntry: Entry = { id: localEntryId, type: "message", role: "user", timestamp: new Date().toISOString(), content: [{ type: "text", text: message }] }; setEntries((current) => [...current, userEntry]); const prompt = message; setMessage(""); setBusy(true); setStreaming(""); setActivities([{ id: "agent", kind: "agent", status: "running", name: "Pi", summary: "Pi is working…" }]); const controller = new AbortController(); abortRef.current = controller; const response = await call(`profile/${profile}/api/sessions/${id}/chat/stream`, { method: "POST", body: JSON.stringify({ message: prompt }), signal: controller.signal }); const reader = response.body?.getReader(), decoder = new TextDecoder(); let buffer = "", complete = false; while (reader && !complete) { const part = await reader.read(); if (part.done) break; buffer += decoder.decode(part.value, { stream: true }); const events = buffer.split("\n\n"); buffer = events.pop() ?? ""; for (const item of events) { const data = item.split("\n").find((line) => line.startsWith("data: "))?.slice(6); if (!data) continue; const payload = JSON.parse(data); if (typeof payload.error === "string") throw new Error(payload.error); const eventName = item.split("\n").find((line) => line.startsWith("event: "))?.slice(7); if (eventName === "activity" && typeof payload.id === "string") setActivities((current) => { const next = payload as Activity, index = current.findIndex((activity) => activity.id === next.id); return index < 0 ? [...current, next] : current.map((activity, itemIndex) => itemIndex === index ? { ...activity, ...next } : activity); }); if (payload.text) setStreaming((current) => current + payload.text); if (typeof payload.response === "string") { setStreaming(payload.response); complete = true; break; } } } await loadConversation(id, false); setEntries((current) => current.filter((entry) => entry.id !== localEntryId)); await loadSessions(); } catch (cause) { if ((cause as Error).name !== "AbortError") setError((cause as Error).message); } finally { abortRef.current = undefined; setBusy(false); setStreaming(""); setActivities([]); } }
49
+ return <Card className="h-[calc(100vh-215px)] min-h-[520px] p-0"><div className="flex h-full min-h-0 overflow-hidden rounded-xl"><aside className={`${sidebar ? "w-96" : "w-0"} flex shrink-0 flex-col overflow-hidden border-r border-zinc-200 bg-zinc-50 transition-all`}><div className="flex h-full w-96 min-h-0 flex-col p-4"><div className="mb-4 flex items-center justify-between"><h2 className="font-semibold">Conversations</h2><div className="relative"><Button title="New session" onClick={() => setNewMenu((current) => !current)}><Plus size={17}/></Button>{newMenu && <div className="absolute right-0 z-20 mt-1 w-48 rounded-lg border border-zinc-200 bg-white p-1 shadow-lg"><button className="w-full rounded px-3 py-2 text-left text-sm hover:bg-zinc-100" onClick={create}>Regular session</button><button className="w-full rounded px-3 py-2 text-left text-sm hover:bg-zinc-100" onClick={() => void openApplicationDialog()}>Application session</button></div>}</div></div><label className="mb-3 flex items-center gap-2 text-xs text-zinc-600"><input type="checkbox" checked={showPulseExecutions} onChange={(event) => setShowPulseExecutions(event.target.checked)}/>Show Pulse sessions</label><div className="relative mb-3"><Search className="absolute left-3 top-2.5 text-zinc-400" size={16}/><Input className="pl-9" value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Search sessions"/></div><div className="min-h-0 flex-1 space-y-1 overflow-y-auto pr-1">{filtered.map((session) => <button key={session.id} onClick={() => { setCopiedSessionId(false); openConversation(session.id); }} className={`w-full rounded-lg p-3 text-left hover:bg-white ${selected === session.id ? "bg-white shadow-sm ring-1 ring-blue-200" : ""}`}><div className="flex items-center gap-2"><MessageSquare size={15} className="text-blue-600"/><span className="min-w-0 flex-1 truncate text-sm font-medium">{session.kind !== "conversation" ? session.pulse?.name || (session.kind === "pulse-only" ? "Pulse result" : "Pulse execution") : session.name || session.preview || "New conversation"}</span><span className="text-xs text-zinc-400">{ago(session.updatedAt)}</span></div><p className="mt-1 truncate text-xs text-zinc-500">{session.kind !== "conversation" ? session.kind === "pulse-only" ? "Injected Pulse result" : `${session.pulse?.type} · ${session.pulse?.status}` : session.preview || `${session.messageCount} messages`}</p><p className="mt-1 truncate font-mono text-[10px] text-zinc-400">{session.id}</p></button>)}{filtered.length === 0 && <p className="p-4 text-center text-sm text-zinc-500">No sessions found.</p>}</div></div></aside><section className="flex min-h-0 min-w-0 flex-1 flex-col"><header className="flex items-center gap-3 border-b border-zinc-200 px-5 py-4"><Button className="bg-zinc-100 text-zinc-700 hover:bg-zinc-200" onClick={() => setSidebar((current) => !current)}><PanelLeft size={17}/></Button><div className="min-w-0"><div className="flex items-center gap-1">{!editingName && <h2 className="truncate font-semibold">{selected ? activeSession?.name || activeSession?.preview || "Untitled session" : "New conversation"}</h2>}{selected && !editingName && <button className="shrink-0 rounded p-1 text-zinc-400 hover:bg-zinc-100 hover:text-zinc-900" title="Rename session" onClick={() => { setSessionName(activeSession?.name || activeSession?.preview || ""); setEditingName(true); }}><Pencil size={14}/></button>}</div>{selected && editingName && <div className="mt-2 flex items-center gap-1"><Input autoFocus className="h-8 min-w-64" value={sessionName} onChange={(event) => setSessionName(event.target.value)} onKeyDown={(event) => { if (event.key === "Enter") void rename(); if (event.key === "Escape") setEditingName(false); }}/><button className="rounded p-1.5 text-emerald-700 hover:bg-emerald-50" title="Save session name" onClick={() => void rename()}><Check size={16}/></button><button className="rounded p-1.5 text-zinc-500 hover:bg-zinc-100" title="Cancel" onClick={() => setEditingName(false)}><X size={16}/></button></div>}<p className="text-xs text-zinc-500">Profile: {profile}</p>{selected && <div className="mt-1 flex items-center gap-1 text-xs text-zinc-400"><span className="truncate font-mono">{selected}</span><button className="shrink-0 rounded p-1 hover:bg-zinc-100" title="Copy session ID" onClick={() => void copySessionId()}>{copiedSessionId ? <Check size={13}/> : <Copy size={13}/>}</button></div>}</div>{selected && <Button className="ml-auto bg-zinc-100 text-zinc-700 hover:bg-red-50 hover:text-red-700" title="Delete conversation" onClick={remove} disabled={busy}><Trash2 size={17}/></Button>}</header><div ref={conversation} onScroll={(event) => { if (event.currentTarget.scrollTop < 80) void loadOlder(); }} className="min-h-0 flex-1 space-y-5 overflow-y-auto bg-white px-5 py-6">{loadingOlder && <div className="py-2 text-center text-xs text-zinc-400">Loading older messages…</div>}{hasMore && !loadingOlder && <div className="py-2 text-center text-xs text-zinc-400">Scroll up to load older messages</div>}{!selected && entries.length === 0 && <div className="mx-auto mt-28 max-w-md text-center"><div className="mx-auto mb-4 w-fit rounded-2xl bg-blue-50 p-4 text-blue-600"><Bot size={30}/></div><h3 className="text-xl font-semibold">Start a conversation</h3><p className="mt-2 text-sm text-zinc-500">Write a message below or choose a previous session.</p></div>}{entries.filter((entry) => entry.role !== "toolResult").map((entry) => <Message key={entry.id} entry={entry}/>) }{busy && <><ActivityFeed activities={activities}/>{streaming && <Message entry={{ id: "streaming", type: "message", role: "assistant", timestamp: new Date().toISOString(), content: [{ type: "text", text: streaming }] }} streaming/>}</>}<div ref={bottom}/></div>{error && <p className="border-t border-red-200 bg-red-50 px-5 py-2 text-sm text-red-700">{error}</p>}<form onSubmit={send} className="border-t border-zinc-200 bg-zinc-50 p-4"><div className="flex gap-3"><textarea className="min-h-14 flex-1 resize-none rounded-xl border border-zinc-200 bg-white p-3 text-sm outline-none focus:border-blue-500" value={message} onChange={(event) => setMessage(event.target.value)} onKeyDown={(event) => { if (event.key === "Enter" && !event.shiftKey && !event.altKey && !event.nativeEvent.isComposing) { event.preventDefault(); event.currentTarget.form?.requestSubmit(); } }} placeholder="Message Pi… (Enter to send, Shift+Enter for a new line)"/>{busy ? <Button type="button" className="bg-zinc-700 hover:bg-zinc-600" title="Stop generation" onClick={() => abortRef.current?.abort()}><CircleStop size={17}/></Button> : <Button className="bg-slate-600 hover:bg-slate-500" title="Send message"><SendHorizontal size={17}/></Button>}</div></form></section></div>{applicationDialog && <div className="fixed inset-0 z-50 grid place-items-center bg-black/30 p-4" role="dialog" aria-modal="true"><Card className="w-full max-w-md"><h3 className="text-lg font-semibold">Application session</h3><p className="mt-1 text-sm text-zinc-500">Creates a manual Console session with Context Memory for the selected Application and identity.</p><div className="mt-4 grid gap-4"><label className="grid gap-1.5 text-sm font-medium">Application<select className="rounded-md border border-zinc-200 px-3 py-2 font-normal" value={application} onChange={(event) => setApplication(event.target.value)}><option value="">Select an Application</option>{applications.map((item) => <option key={item.slug} value={item.slug}>{item.name}</option>)}</select></label><label className="grid gap-1.5 text-sm font-medium">Identity key<Input value={identityKey} onChange={(event) => setIdentityKey(event.target.value)} placeholder="e.g. person@example.com"/></label><div className="flex justify-end gap-2"><Button className="bg-zinc-700 hover:bg-zinc-600" onClick={() => setApplicationDialog(false)}>Cancel</Button><Button disabled={creatingApplicationSession} onClick={() => void createApplicationSession()}>{creatingApplicationSession ? <Loader2 className="animate-spin" size={17}/> : "Create session"}</Button></div></div></Card></div>}</Card>;
44
50
  }
45
- function Message({ entry, streaming = false }: { entry: Entry; streaming?: boolean }) { const [copied, setCopied] = useState(false); const copy = async () => { try { await navigator.clipboard.writeText(text(entry)); setCopied(true); window.setTimeout(() => setCopied(false), 2_000); } catch { /* The copy icon remains available for retry. */ } }; if (entry.type !== "message") return <div className="rounded-lg border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800">{entry.type.replace("_", " ")}: {entry.summary || "Conversation context updated."}</div>; const user = entry.role === "user", assistant = entry.role === "assistant"; const thinking = entry.content?.filter((block) => block.type === "thinking") ?? []; return <article className={`flex gap-3 ${user ? "justify-end" : "justify-start"}`}><div className={`mt-1 grid size-8 shrink-0 place-items-center rounded-full ${user ? "order-2 bg-blue-600 text-white" : "bg-zinc-100 text-zinc-600"}`}>{user ? <User size={16}/> : <Bot size={16}/>}</div><div className={`min-w-0 max-w-[80%] overflow-hidden break-words [overflow-wrap:anywhere] rounded-2xl px-4 py-3 text-sm ${user ? "bg-blue-600 text-white" : entry.isError ? "bg-red-50 text-red-800" : entry.tools?.length ? "bg-[#f7fbfb] text-zinc-800" : "bg-zinc-100 text-zinc-800"}`}><div className={`prose prose-sm max-w-none break-words [&_pre]:whitespace-pre-wrap [&_pre]:break-all ${user ? "prose-invert" : ""}`}><ReactMarkdown remarkPlugins={[remarkGfm]}>{text(entry) || (streaming ? "" : entry.toolName ? `Tool result: ${entry.toolName}` : "")}</ReactMarkdown></div>{streaming && <Loader2 className="mt-2 animate-spin" size={15}/>} {entry.tools?.map((tool, index) => <details open key={`${tool.name}-${index}`} className="mt-3 rounded-lg p-2"><summary className="cursor-pointer font-medium">Tool: {tool.name}</summary><pre className="mt-2 overflow-auto text-xs">{JSON.stringify(tool.arguments, null, 2)}</pre></details>)}{thinking.length > 0 && <details className="mt-3 rounded-lg bg-black/5 p-2"><summary className="flex cursor-pointer items-center gap-1 font-medium">Reasoning <ChevronDown size={14}/></summary>{thinking.map((block, index) => <p key={index} className="mt-2 whitespace-pre-wrap text-xs opacity-80">{block.text}</p>)}</details>}<div className={`mt-2 flex items-center gap-2 text-[11px] ${user ? "text-blue-100" : "text-zinc-400"}`}><span>{entry.role === "toolResult" ? "Tool result" : user ? "You" : "Pi"}</span><span>{new Date(entry.timestamp).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}</span>{text(entry) && <button className="ml-auto" title="Copy" onClick={() => void copy()}>{copied ? <Check size={13}/> : <Copy size={13}/>}</button>}</div></div></article>; }
51
+ function ActivityFeed({ activities }: { activities: Activity[] }) {
52
+ return <div className="max-w-[80%] space-y-1 rounded-xl border border-zinc-200 bg-zinc-50 p-3 text-xs text-zinc-600">{activities.map((activity) => <div key={activity.id} className="flex items-center gap-2"><span className={`size-2 rounded-full ${activity.status === "failed" ? "bg-red-500" : activity.status === "completed" ? "bg-emerald-500" : "animate-pulse bg-[#085c8b]"}`}/><span className="font-medium text-zinc-700">{activity.name}</span><span className="truncate">{activity.summary}</span></div>)}</div>;
53
+ }
54
+
55
+ function Message({ entry, streaming = false }: { entry: Entry; streaming?: boolean }) { const [copied, setCopied] = useState(false); const copy = async () => { try { await navigator.clipboard.writeText(text(entry)); setCopied(true); window.setTimeout(() => setCopied(false), 2_000); } catch { /* The copy icon remains available for retry. */ } }; if (entry.type !== "message") return <div className="rounded-lg border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800">{entry.type.replace("_", " ")}: {entry.summary || "Conversation context updated."}</div>; const user = entry.role === "user", assistant = entry.role === "assistant"; const thinking = entry.content?.filter((block) => block.type === "thinking") ?? []; return <article className={`flex gap-3 ${user ? "justify-end" : "justify-start"}`}><div className={`mt-1 grid size-8 shrink-0 place-items-center rounded-full ${user ? "order-2 bg-[#085c8b] text-white" : "bg-zinc-100 text-zinc-600"}`}>{user ? <User size={16}/> : <Bot size={16}/>}</div><div className={`min-w-0 max-w-[80%] overflow-hidden break-words [overflow-wrap:anywhere] rounded-2xl px-4 py-3 text-sm ${user ? "bg-[#085c8b] text-white" : entry.isError ? "bg-red-50 text-red-800" : entry.tools?.length ? "bg-[#f7fbfb] text-zinc-800" : "bg-zinc-100 text-zinc-800"}`}><div className={`prose prose-sm max-w-none break-words [&_pre]:whitespace-pre-wrap [&_pre]:break-all ${user ? "prose-invert" : ""}`}><ReactMarkdown remarkPlugins={[remarkGfm]}>{text(entry) || (streaming ? "" : entry.toolName ? `Tool result: ${entry.toolName}` : "")}</ReactMarkdown></div>{streaming && <Loader2 className="mt-2 animate-spin" size={15}/>} {entry.tools?.map((tool, index) => <details open key={`${tool.name}-${index}`} className="mt-3 rounded-lg p-2"><summary className="cursor-pointer font-medium">Tool: {tool.name}</summary><pre className="mt-2 overflow-auto text-xs">{JSON.stringify(tool.arguments, null, 2)}</pre></details>)}{thinking.length > 0 && <details className="mt-3 rounded-lg bg-black/5 p-2"><summary className="flex cursor-pointer items-center gap-1 font-medium">Reasoning <ChevronDown size={14}/></summary>{thinking.map((block, index) => <p key={index} className="mt-2 whitespace-pre-wrap text-xs opacity-80">{block.text}</p>)}</details>}<div className={`mt-2 flex items-center gap-2 text-[11px] ${user ? "text-blue-100" : "text-zinc-400"}`}><span>{entry.role === "toolResult" ? "Tool result" : user ? "You" : "Pi"}</span><span>{new Date(entry.timestamp).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}</span>{text(entry) && <button className="ml-auto" title="Copy" onClick={() => void copy()}>{copied ? <Check size={13}/> : <Copy size={13}/>}</button>}</div></div></article>; }
@@ -20,6 +20,7 @@ export async function ConsolePage({ section, applicationSlug, profile = "default
20
20
  else if (section === "guardrails") initial.guardrails = await piGet(`profiles/${profile}/guardrails`);
21
21
  else if (section === "packages") initial.packages = await piGet(`profiles/${profile}/packages`);
22
22
  else if (section === "pulses") initial.pulses = await piGet(`profiles/${profile}/pulses`);
23
+ else if (section === "sequential-workflows") initial.sequentialWorkflows = await piGet(`profiles/${profile}/sequential-workflows/config`);
23
24
  // Tool discovery can require booting a sandboxed Pi runtime. Let the client
24
25
  // render the Tools workspace immediately and load its catalog asynchronously.
25
26
  else if (section === "skills" || section === "extensions") initial.resources = await piGet(`profiles/${profile}/resources/${section}`);
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
  import { createContext, useCallback, useContext, useRef, useState } from "react";
3
3
 
4
- type Profile = { name: string; path: string };
4
+ type Profile = { name: string; path: string; description: string; tags: string[] };
5
5
  type ConsoleState = { profiles: Profile[]; health: string; error: string; initialize: () => Promise<void>; refreshProfiles: () => Promise<Profile[]> };
6
6
  const StateContext = createContext<ConsoleState | undefined>(undefined);
7
7
 
@@ -11,7 +11,7 @@ import { json as jsonLanguage } from "@codemirror/lang-json";
11
11
  import { javascript } from "@codemirror/lang-javascript";
12
12
  import { HighlightStyle, StreamLanguage, syntaxHighlighting } from "@codemirror/language";
13
13
  import { tags } from "@lezer/highlight";
14
- import { Activity, Bot, Box, Check, ChevronDown, Cog, Copy, Eye, FileCode2, GitBranch, Loader2, LogOut, MessageSquare, MoreHorizontal, Play, Plus, RotateCw, Save, Search, Settings2, ShieldCheck, Sparkles, Terminal, Trash2, UserRound, Wrench, X } from "lucide-react";
14
+ import { Activity, Bot, Box, Check, ChevronDown, Cog, Copy, Eye, FileCode2, GitBranch, Loader2, LogOut, MessageSquare, MoreHorizontal, Pencil, Play, Plus, RotateCw, Save, Search, Settings2, ShieldCheck, Sparkles, Terminal, Trash2, UserRound, Wrench, X } from "lucide-react";
15
15
  import { Button } from "@/components/ui/button";
16
16
  import { Card, CardTitle } from "@/components/ui/card";
17
17
  import { Input } from "@/components/ui/input";
@@ -33,11 +33,12 @@ import { SkillResourcesPanel } from "@/components/skill-resources";
33
33
  import { SkillSources } from "@/components/skill-sources";
34
34
  import { PackageResources } from "@/components/package-resources";
35
35
  import { PulseResources } from "@/components/pulse-resources";
36
+ import { SequentialWorkflowConfig } from "@/components/sequential-workflow-config";
36
37
  import { applicationHandlerTemplate } from "../../lib/application-handler-templates";
37
38
 
38
- type Profile = { name: string; path: string };
39
+ type Profile = { name: string; path: string; description: string; tags: string[] };
39
40
  type Resource = { name: string; kind: string; path?: string; source: string; package?: string; enabled: boolean; protected?: boolean };
40
- export type ConsoleInitialData = { settings?: { settings: unknown }; env?: { content: string; exists: boolean }; document?: { content: string }; guardrails?: { content: string }; resources?: { resources: Resource[] }; packages?: { packages: Array<{ name: string; version?: string; description?: string; enabled: boolean; installed: boolean }> }; pulses?: { pulses: Array<{ id: string; name: string; description: string; type: "cron" | "heartbeat"; schedule: string; prompt: string; thread_session_id: string; insertIntoApiSession: boolean; result: string | null; enabled: boolean; nextRunAt: string | null; lastRunAt: string | null }> }; adminConfig?: Record<string, unknown>; models?: string; profileModel?: { providers: Array<{ id: string; models: Array<{ id: string; name: string }> }>; settings?: Record<string, unknown> }; error?: string };
41
+ export type ConsoleInitialData = { settings?: { settings: unknown }; env?: { content: string; exists: boolean }; document?: { content: string }; guardrails?: { content: string }; resources?: { resources: Resource[] }; packages?: { packages: Array<{ name: string; version?: string; description?: string; enabled: boolean; installed: boolean }> }; pulses?: { pulses: Array<{ id: string; name: string; description: string; type: "cron" | "heartbeat"; schedule: string; prompt: string; thread_session_id: string; insertIntoApiSession: boolean; result: string | null; enabled: boolean; nextRunAt: string | null; lastRunAt: string | null }> }; sequentialWorkflows?: { evaluation: { modelType: "llm" } | { modelType: "system_one"; systemOne: { provider: string; model: string; onUncertain: "block" } } }; adminConfig?: Record<string, unknown>; models?: string; profileModel?: { providers: Array<{ id: string; models: Array<{ id: string; name: string }> }>; settings?: Record<string, unknown> }; error?: string };
41
42
  const request = async (path: string, init?: RequestInit) => { const response = await fetch(`/api/pi/${path}`, { ...init, headers: { "content-type": "application/json", ...init?.headers } }); if (!response.ok) { const body = await response.json().catch(() => ({})); throw new Error(body.error?.message ?? body.error ?? "Request failed"); } return response; };
42
43
  const getJson = async <T,>(path: string, init?: RequestInit) => (await request(path, init)).json() as Promise<T>;
43
44
  type EnvState = { value: boolean };
@@ -94,9 +95,22 @@ function Resources({ profile, kind, initial }: { profile: string; kind: "skills"
94
95
 
95
96
  function AdminConfig({ target, title, initial }: { target: "api-server" | "pi-console-webui"; title: string; initial?: Record<string, unknown> }) { const { toast } = useToast(); const [value, setValue] = useState(() => JSON.stringify(initial ?? {}, null, 2)); const save = async () => { try { await fetch(`/api/admin/config/${target}`, { method: "PUT", headers: { "content-type": "application/json" }, body: value }).then(async (response) => { if (!response.ok) throw new Error((await response.json()).error ?? "Unable to save configuration"); }); toast("Configuration saved."); } catch (cause) { toast((cause as Error).message, "error"); } }; const restart = async () => { try { const response = await fetch(`/api/admin/services/${target}/restart`, { method: "POST" }); if (!response.ok) throw new Error("Unable to restart service"); toast("Service restart requested."); } catch (cause) { toast((cause as Error).message, "error"); } }; return <Card><div className="flex items-center justify-between gap-3"><div><CardTitle>{title}</CardTitle><p className="text-sm text-zinc-500">The API token is redacted and preserved when saved.</p></div><div className="flex gap-2"><Button className="bg-zinc-100 text-zinc-700 hover:bg-zinc-200" title="Restart service" onClick={restart}><RotateCw size={17}/></Button><Button className="!bg-[#2bbb77] hover:!bg-[#249b63]" title="Save configuration" onClick={save}><Save size={17}/></Button></div></div><div className="mt-4 overflow-hidden rounded-lg border border-zinc-200"><CodeMirror value={value} height="520px" extensions={[jsonLanguage()]} onChange={setValue}/></div></Card>; }
96
97
 
97
- const profileColors = ["bg-[#19a0dd]"];
98
- const profileColor = (name: string) => profileColors[[...name].reduce((sum, char) => sum + char.charCodeAt(0), 0) % profileColors.length];
98
+ const profileColors = ["bg-sky-500", "bg-emerald-500", "bg-pink-500", "bg-amber-500", "bg-violet-500", "bg-cyan-500", "bg-fuchsia-500", "bg-rose-500"];
99
+ const tagColors = [
100
+ { normal: "border-sky-200 bg-sky-50 text-sky-800", selected: "border-sky-600 bg-sky-600 text-white" },
101
+ { normal: "border-emerald-200 bg-emerald-50 text-emerald-800", selected: "border-emerald-600 bg-emerald-600 text-white" },
102
+ { normal: "border-pink-200 bg-pink-50 text-pink-800", selected: "border-pink-600 bg-pink-600 text-white" },
103
+ { normal: "border-amber-200 bg-amber-50 text-amber-800", selected: "border-amber-600 bg-amber-600 text-white" },
104
+ { normal: "border-violet-200 bg-violet-50 text-violet-800", selected: "border-violet-600 bg-violet-600 text-white" },
105
+ { normal: "border-cyan-200 bg-cyan-50 text-cyan-800", selected: "border-cyan-600 bg-cyan-600 text-white" },
106
+ { normal: "border-fuchsia-200 bg-fuchsia-50 text-fuchsia-800", selected: "border-fuchsia-600 bg-fuchsia-600 text-white" },
107
+ { normal: "border-rose-200 bg-rose-50 text-rose-800", selected: "border-rose-600 bg-rose-600 text-white" },
108
+ ];
109
+ const colorIndex = (value: string, length: number) => [...value].reduce((sum, char) => sum + char.charCodeAt(0), 0) % length;
110
+ const profileColor = (name: string) => profileColors[colorIndex(name, profileColors.length)];
111
+ const tagColor = (tag: string, selected = false) => tagColors[colorIndex(tag.toLocaleLowerCase(), tagColors.length)][selected ? "selected" : "normal"];
99
112
  function ProfileAvatar({ name, size = "size-7" }: { name: string; size?: string }) { return <span className={`grid ${size} shrink-0 place-items-center rounded-full ${profileColor(name)} text-xs font-semibold text-white`}>{name.slice(0, 1).toUpperCase()}</span>; }
113
+ function ProfileTagsEditor({ tags, onChange }: { tags: string[]; onChange: (tags: string[]) => void }) { const [value, setValue] = useState(""), inputRef = useRef<HTMLInputElement>(null); const add = () => { const additions = value.split(",").map((tag) => tag.trim()).filter(Boolean); if (additions.length) onChange([...new Map([...tags, ...additions].map((tag) => [tag.toLocaleLowerCase(), tag])).values()]); setValue(""); }; return <div className="rounded-md border border-zinc-200 p-2"><div className="flex flex-wrap gap-1.5">{tags.map((tag) => <span key={tag} className={`inline-flex items-center gap-1 rounded-full border px-2 py-1 text-xs ${tagColor(tag)}`}>{tag}<button type="button" title={`Remove ${tag}`} onClick={() => onChange(tags.filter((item) => item !== tag))}><X size={12}/></button></span>)}</div><Input ref={inputRef} className="mt-2 h-8 border-0 p-0 shadow-none focus-visible:ring-0" value={value} onChange={(event) => setValue(event.target.value)} onKeyDown={(event) => { if (event.key === "Enter" || event.key === ",") { event.preventDefault(); add(); } if (event.key === "Tab" && value.trim()) { event.preventDefault(); add(); requestAnimationFrame(() => inputRef.current?.focus()); } }} onBlur={add} placeholder="Type a tag and press Enter"/></div>; }
100
114
 
101
115
  type ContextMemoryItem = { id: string; label: string; kind: "operational" | "profile" | "handler"; application?: string; path?: string; content?: string };
102
116
  function ContextMemory({ profile }: { profile: string }) {
@@ -132,6 +146,7 @@ const navigation = [
132
146
  { href: "/extensions", label: "Extensions", icon: FileCode2, color: "text-orange-600" },
133
147
  { href: "/chat", label: "Chat", icon: MessageSquare, color: "text-blue-600" },
134
148
  { href: "/pulses", label: "Pulses", icon: Activity, color: "text-rose-600" },
149
+ { href: "/sequential-workflows", label: "Sequential Workflows", icon: GitBranch, color: "text-violet-600" },
135
150
  { href: "/applications", label: "Applications", icon: Box, color: "text-emerald-600", admin: true },
136
151
  { href: "/models", label: "Models", icon: Bot, color: "text-violet-600", admin: true },
137
152
  { href: "/skill-sources", label: "Skill Sources", icon: GitBranch, color: "text-[#d7c900]", admin: true },
@@ -144,17 +159,19 @@ export function Console({ section = "overview", applicationSlug, initial = {} }:
144
159
  const requestedProfile = searchParams.get("profile") ?? "default";
145
160
  const administrative = searchParams.get("context") === "admin";
146
161
  const { profiles, health, error, initialize, refreshProfiles } = useConsoleState();
147
- const [profile, setProfile] = useState(requestedProfile), [profileMenu, setProfileMenu] = useState(false), [profileFilterInput, setProfileFilterInput] = useState(""), [profileFilter, setProfileFilter] = useState(""), [createDialog, setCreateDialog] = useState(false), [newProfile, setNewProfile] = useState(""), [profileError, setProfileError] = useState(""), [creatingProfile, setCreatingProfile] = useState(false);
162
+ const [profile, setProfile] = useState(requestedProfile), [profileMenu, setProfileMenu] = useState(false), [profileFilterInput, setProfileFilterInput] = useState(""), [profileFilter, setProfileFilter] = useState(""), [selectedProfileTags, setSelectedProfileTags] = useState<string[]>([]), [createDialog, setCreateDialog] = useState(false), [editProfile, setEditProfile] = useState<Profile>(), [newProfile, setNewProfile] = useState(""), [newProfileDescription, setNewProfileDescription] = useState(""), [newProfileTags, setNewProfileTags] = useState<string[]>([]), [cloneProfile, setCloneProfile] = useState(false), [cloneFrom, setCloneFrom] = useState(""), [profileDescription, setProfileDescription] = useState(""), [profileTags, setProfileTags] = useState<string[]>([]), [profileError, setProfileError] = useState(""), [creatingProfile, setCreatingProfile] = useState(false), [savingProfile, setSavingProfile] = useState(false);
148
163
  const profileFilterRef = useRef<HTMLInputElement>(null);
149
164
  useEffect(() => setProfile(requestedProfile), [requestedProfile]);
150
- useEffect(() => { if (!profileMenu) return; setProfileFilterInput(""); setProfileFilter(""); const frame = requestAnimationFrame(() => profileFilterRef.current?.focus()); return () => cancelAnimationFrame(frame); }, [profileMenu]);
165
+ useEffect(() => { if (!profileMenu) return; setProfileFilterInput(""); setProfileFilter(""); setSelectedProfileTags([]); const frame = requestAnimationFrame(() => profileFilterRef.current?.focus()); return () => cancelAnimationFrame(frame); }, [profileMenu]);
151
166
  useEffect(() => { const timer = setTimeout(() => setProfileFilter(profileFilterInput), 500); return () => clearTimeout(timer); }, [profileFilterInput]);
152
167
  const router = useRouter();
153
168
  const selectProfile = (name: string) => { const destination = administrative || section === "overview" ? "/" : `/${section}`; router.replace(`${destination}?profile=${encodeURIComponent(name)}`); };
154
169
  const refresh = async () => { const data = await refreshProfiles(); if (!data.some((item) => item.name === profile)) setProfile("default"); };
155
170
  useEffect(() => { void initialize(); }, [initialize]);
156
- const content = section === "applications" ? applicationSlug ? <ApplicationWorkspace slug={applicationSlug}/> : <Applications/> : administrative && section === "api-server" ? <AdminConfigForm target="api-server" initial={initial.adminConfig}/> : administrative && section === "pi-console-webui" ? <AdminConfigForm target="pi-console-webui" initial={initial.adminConfig}/> : administrative && section === "models" ? <ModelsConfigForm initial={initial.models}/> : administrative && section === "skill-sources" ? <SkillSources/> : section === "chat" ? <ChatWorkspace key={profile} profile={profile}/> : section === "profiles" ? <Profiles profile={profile} refresh={refresh}/> : section === "settings" ? <Settings profile={profile} initial={initial.settings}/> : section === "model" ? <ProfileModelForm profile={profile} initial={initial.profileModel}/> : section === "env" ? <Environment profile={profile} initial={initial.env}/> : section === "soul" ? <MarkdownDocument profile={profile} type="soul" title="SOUL.md" initial={initial.document}/> : section === "guardrails" ? <GuardrailsPanel profile={profile} initial={initial.guardrails}/> : section === "context-memory" ? <ContextMemory profile={profile}/> : section === "skills" ? <SkillResourcesPanel profile={profile} initial={initial.resources}/> : section === "tools" ? <Resources profile={profile} kind="tools" initial={initial.resources}/> : section === "packages" ? <PackageResources profile={profile} initial={initial.packages}/> : section === "pulses" ? <PulseResources profile={profile} initial={initial.pulses}/> : section === "extensions" ? <Resources profile={profile} kind="extensions" initial={initial.resources}/> : <Overview health={health} profiles={profiles} profile={profile}/>;
157
- return <main className="mx-auto w-full max-w-[1800px] px-4 py-4 sm:px-6 sm:py-6"><header className="mb-7 flex flex-wrap items-center gap-4"><div className="mr-auto flex items-center gap-3"><div className="rounded-xl bg-slate-700 p-2 text-white"><Bot size={24}/></div><div><h1 className="text-2xl font-bold tracking-tight">π Console</h1><p className="text-sm text-zinc-500">Manage your Pi API workspace</p></div></div><span className={`inline-flex items-center gap-2 rounded-full px-3 py-1.5 text-sm ${health === "running" ? "bg-emerald-50 text-emerald-700" : "bg-red-50 text-red-700"}`}><Activity size={15}/>{health}</span><div className="relative"><Button className="flex h-10 w-56 items-center justify-start bg-white text-zinc-700 ring-1 ring-zinc-200 hover:bg-zinc-50" title="Select profile" onClick={() => setProfileMenu((current) => !current)}>{administrative ? <span className="grid size-7 shrink-0 place-items-center rounded-full bg-zinc-700 text-white"><Cog size={15}/></span> : <UserRound className="shrink-0 text-zinc-500" size={16}/>}<span className="ml-2 min-w-0 flex-1 truncate text-left">{administrative ? "Administrative Settings" : profile}</span><ChevronDown className="ml-2 shrink-0 text-zinc-400" size={16}/></Button>{profileMenu && <div className="absolute right-0 z-30 mt-2 flex w-[22rem] max-h-[min(680px,calc(100vh-2rem))] flex-col rounded-xl border border-zinc-200 bg-white p-2 shadow-xl"><button className={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm ${administrative ? "bg-zinc-100" : "hover:bg-zinc-50"}`} onClick={() => { router.replace("/applications?context=admin"); setProfileMenu(false); }}><Cog size={16}/><span>Administrative Settings</span></button><div className="my-2 border-t border-zinc-200"/><p className="px-3 pb-1 text-xs font-semibold uppercase tracking-wide text-zinc-500">Profiles</p><label className="mb-2 grid gap-1 px-1 text-xs font-medium text-zinc-600"><span>Filter profiles</span><span className="relative"><Search className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-zinc-400" size={15}/><Input ref={profileFilterRef} className="h-9 border-zinc-300 bg-white pl-9 text-zinc-900" value={profileFilterInput} onChange={(event) => setProfileFilterInput(event.target.value)} placeholder="Search by name"/></span></label><div className="max-h-[calc(min(680px,100vh-2rem)-184px)] overflow-y-auto">{profiles.filter(({ name }) => name.toLocaleLowerCase().includes(profileFilter.trim().toLocaleLowerCase())).map(({ name }) => <div key={name} className={`flex items-center gap-2 rounded-lg px-3 py-2 text-sm ${name === profile ? "bg-zinc-100" : "hover:bg-zinc-50"}`}><button className="flex min-w-0 flex-1 items-center gap-2 text-left" onClick={() => { selectProfile(name); setProfileMenu(false); }}><ProfileAvatar name={name}/><span className="min-w-0 truncate">{name}</span></button>{name !== "default" && <button title={`Delete ${name}`} className="rounded px-2 py-1 text-xs text-zinc-400 hover:bg-red-50 hover:text-red-600" onClick={async () => { if (confirm(`Delete ${name}?`)) { await request(`profiles/${name}`, { method: "DELETE", body: JSON.stringify({ force: true }) }); await refresh(); if (profile === name) selectProfile("default"); } }}><Trash2 size={14}/></button>}</div>)}</div>{profiles.filter(({ name }) => name.toLocaleLowerCase().includes(profileFilter.trim().toLocaleLowerCase())).length === 0 && <p className="px-3 py-6 text-center text-sm text-zinc-500">No profiles match this filter.</p>}<button className="mt-2 flex w-full items-center justify-center rounded-lg border border-dashed border-zinc-300 px-3 py-2 text-zinc-600 hover:bg-zinc-50" title="New profile" onClick={() => { setProfileMenu(false); setCreateDialog(true); setNewProfile(""); setProfileError(""); }}><Plus size={16}/></button></div>}</div><Button className="!bg-[#575d65] hover:!bg-[#454a51]" title="Open system terminal" onClick={() => { const terminal = window.open("/terminal", "pi-console-terminal", "popup=yes,width=1100,height=720,noopener,noreferrer"); terminal?.focus(); }}><Terminal size={17}/></Button><Button className="!bg-[#ddc37b] hover:!bg-[#c7ae68]" title="Sign out" onClick={() => fetch("/api/auth/logout", { method: "POST" }).then(() => location.assign("/login"))}><LogOut size={17}/></Button></header>{error && <div className="mb-5 rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-700">{error}</div>}<nav className="mb-5 flex flex-wrap gap-1 rounded-lg bg-zinc-100 p-1">{navigation.filter((item) => Boolean((item as { admin?: boolean }).admin) === administrative).map((item) => { const Icon = item.icon; const active = section === (item.href === "/" ? "overview" : item.href.slice(1)); return <Link key={item.href} href={`${item.href}?${administrative ? "context=admin" : `profile=${encodeURIComponent(profile)}`}`} className={`rounded-md px-3 py-2 text-sm font-medium ${active ? "bg-white shadow-sm" : "hover:bg-white/70"}`}><Icon className={`mr-2 inline ${item.color}`} size={16}/>{item.label}</Link>; })}</nav>{content}{createDialog && <div className="fixed inset-0 z-50 grid place-items-center bg-black/30 p-4" role="dialog" aria-modal="true"><div className="w-full max-w-md rounded-xl bg-white p-6 shadow-2xl"><h2 className="text-lg font-semibold">Create profile</h2><p className="mt-1 text-sm text-zinc-500">Use letters, numbers, hyphens or underscores. The name must start with a letter.</p><Input className="mt-4" value={newProfile} onChange={(event) => { const value = event.target.value; setNewProfile(value); setProfileError(/^[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(value) ? "" : "Use a valid profile name."); }} placeholder="e.g. work_profile" autoFocus/>{profileError && <p className="mt-2 text-sm text-red-600">{profileError}</p>}<div className="mt-5 flex justify-end gap-2"><Button className="bg-zinc-700 hover:bg-zinc-600" title="Cancel" onClick={() => setCreateDialog(false)}><X size={17}/></Button><Button title="Create profile" disabled={creatingProfile || !/^[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(newProfile)} onClick={async () => { if (creatingProfile) return; try { setCreatingProfile(true); await request("profiles", { method: "POST", body: JSON.stringify({ name: newProfile }) }); await refresh(); selectProfile(newProfile); setCreateDialog(false); } catch (cause) { setProfileError((cause as Error).message); } finally { setCreatingProfile(false); } }}>{creatingProfile ? <Loader2 className="animate-spin" size={17}/> : <Check size={17}/> }</Button></div></div></div>}</main>;
171
+ const availableProfileTags = [...new Map(profiles.flatMap((item) => item.tags).map((tag) => [tag.toLocaleLowerCase(), tag])).values()].sort((left, right) => left.localeCompare(right));
172
+ const filteredProfiles = profiles.filter(({ name, tags }) => [name, ...tags].some((value) => value.toLocaleLowerCase().includes(profileFilter.trim().toLocaleLowerCase())) && selectedProfileTags.every((selected) => tags.some((tag) => tag.toLocaleLowerCase() === selected.toLocaleLowerCase())));
173
+ const content = section === "applications" ? applicationSlug ? <ApplicationWorkspace slug={applicationSlug}/> : <Applications/> : administrative && section === "api-server" ? <AdminConfigForm target="api-server" initial={initial.adminConfig}/> : administrative && section === "pi-console-webui" ? <AdminConfigForm target="pi-console-webui" initial={initial.adminConfig}/> : administrative && section === "models" ? <ModelsConfigForm initial={initial.models}/> : administrative && section === "skill-sources" ? <SkillSources/> : section === "chat" ? <ChatWorkspace key={profile} profile={profile}/> : section === "profiles" ? <Profiles profile={profile} refresh={refresh}/> : section === "settings" ? <Settings profile={profile} initial={initial.settings}/> : section === "model" ? <ProfileModelForm profile={profile} initial={initial.profileModel}/> : section === "env" ? <Environment profile={profile} initial={initial.env}/> : section === "soul" ? <MarkdownDocument profile={profile} type="soul" title="SOUL.md" initial={initial.document}/> : section === "guardrails" ? <GuardrailsPanel profile={profile} initial={initial.guardrails}/> : section === "context-memory" ? <ContextMemory profile={profile}/> : section === "skills" ? <SkillResourcesPanel profile={profile} initial={initial.resources}/> : section === "tools" ? <Resources profile={profile} kind="tools" initial={initial.resources}/> : section === "packages" ? <PackageResources profile={profile} initial={initial.packages}/> : section === "pulses" ? <PulseResources profile={profile} initial={initial.pulses}/> : section === "sequential-workflows" ? <SequentialWorkflowConfig profile={profile} initial={initial.sequentialWorkflows}/> : section === "extensions" ? <Resources profile={profile} kind="extensions" initial={initial.resources}/> : <Overview health={health} profiles={profiles} profile={profile}/>;
174
+ return <main className="mx-auto w-full max-w-[1800px] px-4 py-4 sm:px-6 sm:py-6"><header className="mb-7 flex flex-wrap items-center gap-4"><div className="mr-auto flex items-center gap-3"><div className="rounded-xl bg-slate-700 p-2 text-white"><Bot size={24}/></div><div><h1 className="text-2xl font-bold tracking-tight">π Console</h1><p className="text-sm text-zinc-500">Manage your Pi API workspace</p></div></div><span className={`inline-flex items-center gap-2 rounded-full px-3 py-1.5 text-sm ${health === "running" ? "bg-emerald-50 text-emerald-700" : "bg-red-50 text-red-700"}`}><Activity size={15}/>{health}</span><div className="relative"><Button className="flex h-10 w-56 items-center justify-start bg-white text-zinc-700 ring-1 ring-zinc-200 hover:bg-zinc-50" title="Select profile" onClick={() => setProfileMenu((current) => !current)}>{administrative ? <span className="grid size-7 shrink-0 place-items-center rounded-full bg-zinc-700 text-white"><Cog size={15}/></span> : <UserRound className="shrink-0 text-zinc-500" size={16}/>}<span className="ml-2 min-w-0 flex-1 truncate text-left">{administrative ? "Administrative Settings" : profile}</span><ChevronDown className="ml-2 shrink-0 text-zinc-400" size={16}/></Button>{profileMenu && <div className="absolute right-0 z-30 mt-2 flex w-[min(42rem,calc(100vw-2rem))] max-h-[min(760px,calc(100vh-2rem))] flex-col rounded-xl border border-zinc-200 bg-white p-2 shadow-xl"><button className={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm ${administrative ? "bg-zinc-100" : "hover:bg-zinc-50"}`} onClick={() => { router.replace("/applications?context=admin"); setProfileMenu(false); }}><Cog size={16}/><span>Administrative Settings</span></button><div className="my-2 border-t border-zinc-200"/><p className="px-3 pb-1 text-xs font-semibold uppercase tracking-wide text-zinc-500">Profiles</p><label className="mb-2 grid gap-1 px-1 text-xs font-medium text-zinc-600"><span>Filter profiles</span><span className="relative"><Search className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-zinc-400" size={15}/><Input ref={profileFilterRef} className="h-9 border-zinc-300 bg-white pl-9 text-zinc-900" value={profileFilterInput} onChange={(event) => setProfileFilterInput(event.target.value)} placeholder="Search profiles or tags"/></span></label>{availableProfileTags.length > 0 && <section className="mb-2 border-y border-zinc-100 px-1 py-3"><div className="mb-2 flex items-center justify-between px-2"><p className="text-xs font-semibold uppercase tracking-wide text-zinc-500">Filter tags</p>{selectedProfileTags.length > 0 && <button className="text-xs text-sky-700 hover:text-sky-900" onClick={() => setSelectedProfileTags([])}>Clear</button>}</div><div className="flex max-h-24 flex-wrap gap-1.5 overflow-y-auto px-2">{availableProfileTags.map((tag) => { const selected = selectedProfileTags.some((item) => item.toLocaleLowerCase() === tag.toLocaleLowerCase()); return <button key={tag} className={`rounded-full border px-2 py-1 text-xs transition-colors ${tagColor(tag, selected)} ${selected ? "" : "hover:brightness-95"}`} onClick={() => setSelectedProfileTags((current) => selected ? current.filter((item) => item.toLocaleLowerCase() !== tag.toLocaleLowerCase()) : [...current, tag])}>{tag}</button>; })}</div></section>}<div className="max-h-[calc(min(760px,100vh-2rem)-280px)] overflow-y-auto">{filteredProfiles.map((item) => { const { name, tags } = item; return <div key={name} className={`flex items-center gap-2 rounded-lg px-3 py-2 text-sm ${name === profile ? "bg-zinc-100" : "hover:bg-zinc-50"}`}><button className="flex min-w-0 flex-1 items-center gap-2 text-left" onClick={() => { selectProfile(name); setProfileMenu(false); }}><ProfileAvatar name={name}/><span className="min-w-0 flex-1"><span className="block truncate font-medium">{name}</span>{item.description && <span className="mt-0.5 block truncate text-xs text-zinc-500">{item.description}</span>}{tags.length > 0 && <span className="mt-1 flex flex-wrap gap-1">{tags.map((tag) => <span key={tag} className={`rounded-full border px-1.5 py-0.5 text-[10px] ${tagColor(tag)}`}>{tag}</span>)}</span>}</span></button><button title={`Edit ${name}`} className="rounded-md p-2 text-zinc-400 transition-colors hover:bg-sky-50 hover:text-sky-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500" onClick={(event) => { event.stopPropagation(); setEditProfile(item); setProfileDescription(item.description); setProfileTags(item.tags); setProfileError(""); }}><Pencil size={14}/></button></div>; })}</div>{filteredProfiles.length === 0 && <p className="px-3 py-6 text-center text-sm text-zinc-500">No profiles match this filter.</p>}<button className="mt-2 flex w-full items-center justify-center rounded-lg border border-dashed border-zinc-300 px-3 py-2 text-zinc-600 hover:bg-zinc-50" title="New profile" onClick={() => { setProfileMenu(false); setCreateDialog(true); setNewProfile(""); setNewProfileDescription(""); setNewProfileTags([]); setCloneProfile(false); setCloneFrom(""); setProfileError(""); }}><Plus size={16}/></button></div>}</div><Button className="!bg-[#575d65] hover:!bg-[#454a51]" title="Open system terminal" onClick={() => { const terminal = window.open("/terminal", "pi-console-terminal", "popup=yes,width=1100,height=720,noopener,noreferrer"); terminal?.focus(); }}><Terminal size={17}/></Button><Button className="!bg-[#ddc37b] hover:!bg-[#c7ae68]" title="Sign out" onClick={() => fetch("/api/auth/logout", { method: "POST" }).then(() => location.assign("/login"))}><LogOut size={17}/></Button></header>{error && <div className="mb-5 rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-700">{error}</div>}<nav className="mb-5 flex flex-wrap gap-1 rounded-lg bg-zinc-100 p-1">{navigation.filter((item) => Boolean((item as { admin?: boolean }).admin) === administrative).map((item) => { const Icon = item.icon; const active = section === (item.href === "/" ? "overview" : item.href.slice(1)); return <Link key={item.href} href={`${item.href}?${administrative ? "context=admin" : `profile=${encodeURIComponent(profile)}`}`} className={`rounded-md px-3 py-2 text-sm font-medium ${active ? "bg-white shadow-sm" : "hover:bg-white/70"}`}><Icon className={`mr-2 inline ${item.color}`} size={16}/>{item.label}</Link>; })}</nav>{content}{createDialog && <div className="fixed inset-0 z-50 grid place-items-center bg-black/30 p-4" role="dialog" aria-modal="true"><div className="w-full max-w-md rounded-xl bg-white p-6 shadow-2xl"><h2 className="text-lg font-semibold">Create profile</h2><p className="mt-1 text-sm text-zinc-500">Use letters, numbers, hyphens or underscores. The name must start with a letter.</p><Input className="mt-4" value={newProfile} onChange={(event) => { const value = event.target.value; setNewProfile(value); setProfileError(/^[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(value) ? "" : "Use a valid profile name."); }} placeholder="e.g. work_profile" autoFocus/><label className="mt-4 grid gap-1.5 text-sm font-medium text-zinc-700">Description<textarea className="min-h-24 resize-y rounded-md border border-zinc-200 px-3 py-2 text-sm font-normal outline-none focus:border-sky-500 focus:ring-1 focus:ring-sky-500" value={newProfileDescription} onChange={(event) => setNewProfileDescription(event.target.value)} maxLength={500} placeholder="What is this profile for?"/></label><label className="mt-4 grid gap-1.5 text-sm font-medium text-zinc-700">Tags<ProfileTagsEditor tags={newProfileTags} onChange={setNewProfileTags}/></label>{profiles.some((item) => item.name !== "default") && <div className="mt-4 rounded-lg border border-zinc-200 p-3"><label className="flex items-center justify-between gap-3 text-sm font-medium text-zinc-700"><span>Clone an existing profile</span><Switch checked={cloneProfile} onCheckedChange={(enabled) => { setCloneProfile(enabled); if (!enabled) setCloneFrom(""); }}/></label>{cloneProfile && <label className="mt-3 grid gap-1.5 text-sm font-medium text-zinc-700">Source profile<select className="rounded-md border border-zinc-200 bg-white px-3 py-2 text-sm font-normal" value={cloneFrom} onChange={(event) => setCloneFrom(event.target.value)}><option value="">Select a profile</option>{profiles.filter((item) => item.name !== "default").map((item) => <option key={item.name} value={item.name}>{item.name}</option>)}</select></label>}</div>}{profileError && <p className="mt-2 text-sm text-red-600">{profileError}</p>}<div className="mt-5 flex justify-end gap-2"><Button className="bg-zinc-700 hover:bg-zinc-600" title="Cancel" onClick={() => setCreateDialog(false)}><X size={17}/></Button><Button title="Create profile" disabled={creatingProfile || !/^[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(newProfile) || (cloneProfile && !cloneFrom)} onClick={async () => { if (creatingProfile) return; try { setCreatingProfile(true); await request("profiles", { method: "POST", body: JSON.stringify({ name: newProfile, description: newProfileDescription, tags: newProfileTags, ...(cloneProfile ? { cloneFrom } : {}) }) }); await refresh(); selectProfile(newProfile); setCreateDialog(false); } catch (cause) { setProfileError((cause as Error).message); } finally { setCreatingProfile(false); } }}>{creatingProfile ? <Loader2 className="animate-spin" size={17}/> : <Check size={17}/> }</Button></div></div></div>}{editProfile && <div className="fixed inset-0 z-50 grid place-items-center bg-black/30 p-4" role="dialog" aria-modal="true"><div className="w-full max-w-2xl rounded-xl bg-white p-6 shadow-2xl"><div className="flex items-start justify-between gap-4"><div><h2 className="text-lg font-semibold">Edit <span className="text-sky-700">{editProfile.name}</span></h2><p className="mt-1 text-sm text-zinc-500">Update this profile's description and tags.</p></div><Button className="bg-zinc-100 text-zinc-700 hover:bg-zinc-200" title="Close" onClick={() => setEditProfile(undefined)}><X size={17}/></Button></div><div className="mt-5 grid gap-4"><label className="grid gap-1.5 text-sm font-medium text-zinc-700">Description<textarea className="min-h-28 resize-y rounded-md border border-zinc-200 px-3 py-2 text-sm font-normal outline-none focus:border-sky-500 focus:ring-1 focus:ring-sky-500" value={profileDescription} onChange={(event) => setProfileDescription(event.target.value)} maxLength={500} placeholder="What is this profile for?"/></label><label className="grid gap-1.5 text-sm font-medium text-zinc-700">Tags<ProfileTagsEditor tags={profileTags} onChange={setProfileTags}/></label>{profileError && <p className="text-sm text-red-600">{profileError}</p>}</div><div className="mt-6 flex items-center justify-between gap-2"><Button className="bg-red-600 hover:bg-red-500" title={`Delete ${editProfile.name}`} disabled={editProfile.name === "default"} onClick={async () => { if (!confirm(`Delete ${editProfile.name}?`)) return; try { await request(`profiles/${editProfile.name}`, { method: "DELETE", body: JSON.stringify({ force: true }) }); await refresh(); if (profile === editProfile.name) selectProfile("default"); setEditProfile(undefined); } catch (cause) { setProfileError((cause as Error).message); } }}><Trash2 size={17}/></Button><div className="flex gap-2"><Button className="!bg-[#2bbb77] hover:!bg-[#249b63]" title="Save profile" disabled={savingProfile} onClick={async () => { try { setSavingProfile(true); await request(`profiles/${editProfile.name}`, { method: "PATCH", body: JSON.stringify({ description: profileDescription, tags: profileTags }) }); await refresh(); setEditProfile(undefined); } catch (cause) { setProfileError((cause as Error).message); } finally { setSavingProfile(false); } }}>{savingProfile ? <Loader2 className="animate-spin" size={17}/> : <Save size={17}/>}</Button></div></div></div></div>}</main>;
158
175
  }
159
176
 
160
177
  function SkillResources({ profile, initial }: { profile: string; initial?: { resources: Resource[] } }) {
@@ -63,7 +63,7 @@ export function GuardrailsPanel({ profile, initial }: { profile: string; initial
63
63
  const saveDialog = async () => {
64
64
  if (!valid || saving) return;
65
65
  const file = editing?.file ?? `${form.name}.md`;
66
- if (!editing && items.some((item) => item.name === form.name)) { setNotice("A guardrail with this name already exists."); return; }
66
+ if (items.some((item) => item.name === form.name && item.name !== editing?.name)) { setNotice("A guardrail with this name already exists."); return; }
67
67
  try {
68
68
  setSaving(true);
69
69
  await request(`profiles/${profile}/guardrails/${encodeURIComponent(file)}/document`, { method: "PUT", body: JSON.stringify({ content: form.prompt }) });
@@ -74,5 +74,5 @@ export function GuardrailsPanel({ profile, initial }: { profile: string; initial
74
74
  } catch (error) { setNotice((error as Error).message); } finally { setSaving(false); }
75
75
  };
76
76
  useEffect(() => { if (initial) { try { setItems(parse(initial.content)); } catch (error) { setNotice((error as Error).message); } return; } void load(); }, [profile, initial]);
77
- return <><Card><div className="flex flex-wrap items-center justify-between gap-3"><div><CardTitle>Guardrails</CardTitle><p className="mt-1 text-sm text-zinc-500">Rules are enabled per profile. Markdown prompts are global and may be shared.</p></div><div className="flex gap-2"><Button className="bg-zinc-100 text-zinc-700 hover:bg-zinc-200" title="Reload guardrails" onClick={() => void load()}><RotateCw size={17}/></Button><Button title="Add guardrail" onClick={openCreate}><Plus size={17}/></Button></div></div>{notice && <p className="mt-3 text-sm text-red-700">{notice}</p>}<div className="mt-5 overflow-x-auto"><table className="w-full text-left text-sm"><thead className="border-b border-zinc-200 text-zinc-500"><tr><th className="p-3">Name</th><th className="p-3">Stage</th><th className="p-3">Mode</th><th className="p-3">Order</th><th className="p-3">Enabled</th><th className="p-3"/></tr></thead><tbody>{[...items].sort((a, b) => a.stage.localeCompare(b.stage) || a.order - b.order || a.name.localeCompare(b.name)).map((guardrail) => <tr key={guardrail.name} className="border-b border-zinc-100"><td className="p-3"><p className="font-medium">{guardrail.name}</p><p className="text-xs text-zinc-400">{guardrail.file}</p></td><td className="p-3">{stages.find((option) => option.value === guardrail.stage)?.label}</td><td className="p-3">{modes.find((option) => option.value === guardrail.mode)?.label}</td><td className="p-3">{guardrail.order}</td><td className="p-3"><Switch checked={guardrail.enabled !== false} onCheckedChange={async (enabled) => { try { await saveConfig(items.map((item) => item.name === guardrail.name ? { ...item, enabled } : item)); toast(`Guardrail ${enabled ? "enabled" : "disabled"}.`); } catch (error) { setNotice((error as Error).message); } }}/></td><td className="whitespace-nowrap p-3"><button className="rounded p-2 text-zinc-400 hover:bg-zinc-100 hover:text-zinc-900" title={`Edit ${guardrail.name}`} onClick={() => void openEdit(guardrail)}><FileCode2 size={17}/></button><button className="rounded p-2 text-zinc-400 hover:bg-red-50 hover:text-red-600" title={`Delete ${guardrail.name}`} onClick={async () => { if (!confirm(`Delete guardrail '${guardrail.name}' from profile '${profile}'? Its global prompt file will be kept for other profiles.`)) return; try { await saveConfig(items.filter((item) => item.name !== guardrail.name)); toast("Guardrail deleted from this profile."); } catch (error) { setNotice((error as Error).message); } }}><Trash2 size={17}/></button></td></tr>)}{items.length === 0 && <tr><td className="p-5 text-zinc-500" colSpan={6}>No guardrails configured for this profile.</td></tr>}</tbody></table></div></Card>{dialog && <div className="fixed inset-0 z-50 grid place-items-center bg-black/30 p-4" role="dialog" aria-modal="true"><div className="max-h-[94vh] w-full max-w-6xl overflow-y-auto rounded-xl bg-white p-6 shadow-2xl"><header className="mb-6 flex items-start justify-between gap-4"><div><h2 className="text-xl font-semibold">{dialog === "create" ? "Create guardrail" : `Edit ${editing?.name}`}</h2><p className="mt-1 text-sm text-zinc-500">{dialog === "create" ? "Configure the profile rule and its global Markdown instructions." : "Changes to the Markdown prompt affect every profile that uses this file."}</p></div><div className="flex gap-2"><Button className="!bg-[#2bbb77] hover:!bg-[#249b63]" title={dialog === "create" ? "Create guardrail" : "Save all guardrail changes"} disabled={!valid} onClick={() => void saveDialog()}>{<Check size={17}/>}</Button><Button className="bg-zinc-100 text-zinc-700 hover:bg-zinc-200" title="Close" onClick={close}><X size={17}/></Button></div></header><div className="grid gap-8 lg:grid-cols-[320px_minmax(0,1fr)]"><section className="grid content-start gap-5"><label className="grid gap-1.5 text-sm font-semibold text-zinc-800"><span>Name</span><Input disabled={dialog === "edit"} value={form.name} onChange={(event) => setForm({ ...form, name: event.target.value })} placeholder="e.g. safe-output"/><span className="text-xs font-normal text-zinc-500">{dialog === "create" ? <>Uses lowercase letters, numbers and hyphens. File: <code>{form.name || "name"}.md</code>.</> : <>Names cannot change after creation. File: <code>{editing?.file}</code>.</>}</span></label><OptionSelect label="Stage" value={form.stage} options={stages} onChange={(stage) => setForm({ ...form, stage })}/><OptionSelect label="Mode" value={form.mode} options={modes} onChange={(mode) => setForm({ ...form, mode })}/><label className="grid gap-1.5 text-sm font-semibold text-zinc-800"><span>Order</span><Input type="number" min="0" value={form.order} onChange={(event) => setForm({ ...form, order: event.target.value })}/><span className="text-xs font-normal text-zinc-500">Lower values run first within the same stage.</span></label></section><section className="min-w-0"><label className="mb-2 block text-sm font-semibold text-zinc-800">Markdown instructions</label><p className="mb-3 text-xs text-zinc-500">Syntax-highlighted Markdown editor. This is instruction text only; preview and rich editing are intentionally disabled.</p><div className="overflow-hidden rounded-lg border border-zinc-200"><CodeMirror value={form.prompt} height="580px" extensions={[markdown()]} onChange={(prompt) => setForm({ ...form, prompt })}/></div></section></div></div></div>}</>;
77
+ return <><Card><div className="flex flex-wrap items-center justify-between gap-3"><div><CardTitle>Guardrails</CardTitle><p className="mt-1 text-sm text-zinc-500">Rules are enabled per profile. Markdown prompts are global and may be shared.</p></div><div className="flex gap-2"><Button className="bg-zinc-100 text-zinc-700 hover:bg-zinc-200" title="Reload guardrails" onClick={() => void load()}><RotateCw size={17}/></Button><Button title="Add guardrail" onClick={openCreate}><Plus size={17}/></Button></div></div>{notice && <p className="mt-3 text-sm text-red-700">{notice}</p>}<div className="mt-5 overflow-x-auto"><table className="w-full text-left text-sm"><thead className="border-b border-zinc-200 text-zinc-500"><tr><th className="p-3">Name</th><th className="p-3">Stage</th><th className="p-3">Mode</th><th className="p-3">Order</th><th className="p-3">Enabled</th><th className="p-3"/></tr></thead><tbody>{[...items].sort((a, b) => a.stage.localeCompare(b.stage) || a.order - b.order || a.name.localeCompare(b.name)).map((guardrail) => <tr key={guardrail.name} className="border-b border-zinc-100"><td className="p-3"><p className="font-medium">{guardrail.name}</p><p className="text-xs text-zinc-400">{guardrail.file}</p></td><td className="p-3">{stages.find((option) => option.value === guardrail.stage)?.label}</td><td className="p-3">{modes.find((option) => option.value === guardrail.mode)?.label}</td><td className="p-3">{guardrail.order}</td><td className="p-3"><Switch checked={guardrail.enabled !== false} onCheckedChange={async (enabled) => { try { await saveConfig(items.map((item) => item.name === guardrail.name ? { ...item, enabled } : item)); toast(`Guardrail ${enabled ? "enabled" : "disabled"}.`); } catch (error) { setNotice((error as Error).message); } }}/></td><td className="whitespace-nowrap p-3"><button className="rounded p-2 text-zinc-400 hover:bg-zinc-100 hover:text-zinc-900" title={`Edit ${guardrail.name}`} onClick={() => void openEdit(guardrail)}><FileCode2 size={17}/></button><button className="rounded p-2 text-zinc-400 hover:bg-red-50 hover:text-red-600" title={`Delete ${guardrail.name}`} onClick={async () => { if (!confirm(`Delete guardrail '${guardrail.name}' from profile '${profile}'? Its global prompt file will be kept for other profiles.`)) return; try { await saveConfig(items.filter((item) => item.name !== guardrail.name)); toast("Guardrail deleted from this profile."); } catch (error) { setNotice((error as Error).message); } }}><Trash2 size={17}/></button></td></tr>)}{items.length === 0 && <tr><td className="p-5 text-zinc-500" colSpan={6}>No guardrails configured for this profile.</td></tr>}</tbody></table></div></Card>{dialog && <div className="fixed inset-0 z-50 grid place-items-center bg-black/30 p-4" role="dialog" aria-modal="true"><div className="max-h-[94vh] w-full max-w-6xl overflow-y-auto rounded-xl bg-white p-6 shadow-2xl"><header className="mb-6 flex items-start justify-between gap-4"><div><h2 className="text-xl font-semibold">{dialog === "create" ? "Create guardrail" : `Edit ${editing?.name}`}</h2><p className="mt-1 text-sm text-zinc-500">{dialog === "create" ? "Configure the profile rule and its global Markdown instructions." : "Changes to the Markdown prompt affect every profile that uses this file."}</p></div><div className="flex gap-2"><Button className="!bg-[#2bbb77] hover:!bg-[#249b63]" title={dialog === "create" ? "Create guardrail" : "Save all guardrail changes"} disabled={!valid} onClick={() => void saveDialog()}>{<Check size={17}/>}</Button><Button className="bg-zinc-100 text-zinc-700 hover:bg-zinc-200" title="Close" onClick={close}><X size={17}/></Button></div></header><div className="grid gap-8 lg:grid-cols-[320px_minmax(0,1fr)]"><section className="grid content-start gap-5"><label className="grid gap-1.5 text-sm font-semibold text-zinc-800"><span>Name</span><Input value={form.name} onChange={(event) => setForm({ ...form, name: event.target.value })} placeholder="e.g. safe-output"/><span className="text-xs font-normal text-zinc-500">{dialog === "create" ? <>Uses lowercase letters, numbers and hyphens. File: <code>{form.name || "name"}.md</code>.</> : <>Uses lowercase letters, numbers and hyphens. The shared Markdown file remains <code>{editing?.file}</code>.</>}</span></label><OptionSelect label="Stage" value={form.stage} options={stages} onChange={(stage) => setForm({ ...form, stage })}/><OptionSelect label="Mode" value={form.mode} options={modes} onChange={(mode) => setForm({ ...form, mode })}/><label className="grid gap-1.5 text-sm font-semibold text-zinc-800"><span>Order</span><Input type="number" min="0" value={form.order} onChange={(event) => setForm({ ...form, order: event.target.value })}/><span className="text-xs font-normal text-zinc-500">Lower values run first within the same stage.</span></label></section><section className="min-w-0"><label className="mb-2 block text-sm font-semibold text-zinc-800">Markdown instructions</label><p className="mb-3 text-xs text-zinc-500">Syntax-highlighted Markdown editor. This is instruction text only; preview and rich editing are intentionally disabled.</p><div className="overflow-hidden rounded-lg border border-zinc-200"><CodeMirror value={form.prompt} height="580px" extensions={[markdown()]} onChange={(prompt) => setForm({ ...form, prompt })}/></div></section></div></div></div>}</>;
78
78
  }
@@ -8,7 +8,7 @@ import { useToast } from "@/components/toast";
8
8
 
9
9
  type Provider = { id: string; models: Array<{ id: string; name: string }> };
10
10
  type ModelCatalog = { providers: Provider[] };
11
- type ApplicationSession = { application: string; sessionPrefix: string; sessionId: string };
11
+ type ApplicationSession = { application: string; sessionPrefix: string; sessionId: string; sessionType: "stateful" | "stateless" };
12
12
 
13
13
  export function ProfileModelForm({ profile, initial }: { profile: string; initial?: ModelCatalog & { settings?: Record<string, unknown> } }) {
14
14
  const { toast } = useToast();
@@ -32,7 +32,7 @@ export function ProfileModelForm({ profile, initial }: { profile: string; initia
32
32
  const sessionsBody = await sessionsResponse.json();
33
33
  if (!sessionsResponse.ok) throw new Error(sessionsBody.error ?? "Model saved, but active sessions could not be loaded.");
34
34
  const unique = new Map<string, ApplicationSession>();
35
- for (const session of sessionsBody.sessions as ApplicationSession[]) unique.set(`${session.application}:${session.sessionPrefix}`, session);
35
+ for (const session of sessionsBody.sessions as ApplicationSession[]) if (session.sessionType === "stateful") unique.set(`${session.application}:${session.sessionPrefix}`, session);
36
36
  setActiveSessions([...unique.values()]);
37
37
  setRolloverDialog(true);
38
38
  } catch (error) { toast((error as Error).message, "error"); } finally { setSaving(false); }