Files
fusion/packages/dashboard/app/components/command-center/IdeationPanel.tsx
gsxdsm d51ce46db5 FN-8295: add persisted ideation mission handoffs
Persist bounded ideation sessions through agent tools, the Command Center, and atomic Mission convergence.

- Store ideation sessions and divergent candidates in PostgreSQL with async APIs and migration support.
- Expose gated ideation tools, chat routes, and agent lifecycle integration.
- Add the Ideation panel, documentation, release metadata, and regression coverage.

Files changed:
 .changeset/fn-8295-ideation-diverge-converge.md    |   7 ++
 docs/ideation/persisted-diverge-converge.md        |  22 ++++
 docs/missions.md                                   |   4 +
 .../__tests__/postgres/ideation-store.pg.test.ts   |  57 +++++++++
 packages/core/src/async-ideation-store-queries.ts  | 117 +++++++++++++++++
 packages/core/src/async-ideation-store.ts          | 138 +++++++++++++++++++++
 packages/core/src/async-mission-store.ts           |  27 ++--
 packages/core/src/ideation-types.ts                |  69 +++++++++++
 packages/core/src/index.ts                         |   3 +
 .../core/src/postgres/migrations/0022_ideation.sql |  67 ++++++++++
 packages/core/src/postgres/schema-applier.ts       |  18 ++-
 packages/core/src/postgres/schema/project.ts       |  49 +++++++-
 packages/core/src/store.ts                         |   8 +-
 packages/core/src/task-store/remaining-ops-8.ts    |  14 +++
 .../components/command-center/CommandCenter.tsx    |   7 +-
 .../components/command-center/IdeationPanel.css    |  18 +++
 .../components/command-center/IdeationPanel.tsx    |  58 +++++++++
 .../__tests__/CommandCenter.test.tsx               |   6 +-
 .../dashboard/src/__tests__/chat-manager.test.ts   |   1 +
 packages/dashboard/src/__tests__/chat.test.ts      |   1 +
 .../__tests__/ideation-tool-route-parity.test.ts   |  29 +++++
 packages/dashboard/src/chat.ts                     |   4 +
 packages/dashboard/src/ideation-routes.ts          |  50 ++++++++
 .../src/routes/register-integrated-routers.ts      |   2 +
 .../src/__tests__/agent-ideation-tools.test.ts     |  40 ++++++
 .../src/__tests__/gating-classifications.test.ts   |  16 +++
 .../src/__tests__/permanent-agent-gating.test.ts   |   2 +
 packages/engine/src/agent-heartbeat.ts             |   4 +-
 packages/engine/src/agent-tools.ts                 |  67 ++++++++++
 packages/engine/src/executor.ts                    |   2 +
 packages/engine/src/gating-classifications.ts      |   8 ++
 packages/engine/src/index.ts                       |   1 +
 packages/engine/src/triage.ts                      |   2 +
 33 files changed, 897 insertions(+), 21 deletions(-)

Fusion-Task-Id: FN-8295

Fusion-Task-Lineage: 1b8b0752-22bd-4b2f-aebd-4305c63abcf9

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2026-07-18 13:18:08 -07:00

59 lines
5.1 KiB
TypeScript

import { FormEvent, useEffect, useState } from "react";
import { Lightbulb } from "lucide-react";
import type { IdeationCandidate, IdeationSessionWithCandidates } from "@fusion/core";
import { withProjectId } from "../../api/legacy";
import "./IdeationPanel.css";
async function ideationRequest<T>(path: string, projectId: string | undefined, init?: RequestInit): Promise<T> {
const response = await fetch(withProjectId(`/api/ideation${path}`, projectId), {
...init,
headers: { "Content-Type": "application/json", ...init?.headers },
});
if (!response.ok) throw new Error(await response.text() || "Ideation request failed");
return response.json() as Promise<T>;
}
/*
FNXC:Ideation 2026-07-30-15:30:
Command Center gives humans the same bounded session → candidates → canonical
Mission convergence operation agents use. The visible Mission ID is persisted
handoff evidence, not a copied document or a separate dashboard-only roadmap.
*/
export function IdeationPanel({ projectId }: { projectId?: string }) {
const [sessions, setSessions] = useState<IdeationSessionWithCandidates[]>([]);
const [selectedId, setSelectedId] = useState<string>();
const [title, setTitle] = useState("");
const [candidate, setCandidate] = useState("");
const [error, setError] = useState<string>();
const selected = sessions.find((session) => session.id === selectedId);
const refresh = async () => {
const listed = await ideationRequest<Array<IdeationSessionWithCandidates>>("/", projectId);
const hydrated = await Promise.all(listed.map((session) => ideationRequest<IdeationSessionWithCandidates>(`/${encodeURIComponent(session.id)}`, projectId)));
setSessions(hydrated);
setSelectedId((current) => current && hydrated.some((session) => session.id === current) ? current : hydrated[0]?.id);
};
useEffect(() => { void refresh().catch((reason) => setError(reason instanceof Error ? reason.message : String(reason))); }, [projectId]);
const submit = async (event: FormEvent) => {
event.preventDefault(); setError(undefined);
try { const created = await ideationRequest<IdeationSessionWithCandidates>("/", projectId, { method: "POST", body: JSON.stringify({ title }) }); setTitle(""); await refresh(); setSelectedId(created.id); }
catch (reason) { setError(reason instanceof Error ? reason.message : String(reason)); }
};
const addCandidate = async (event: FormEvent) => {
event.preventDefault(); if (!selected || !candidate.trim()) return; setError(undefined);
try { await ideationRequest(`/${encodeURIComponent(selected.id)}/candidates`, projectId, { method: "POST", body: JSON.stringify({ content: candidate, origin: "human" }) }); setCandidate(""); await refresh(); }
catch (reason) { setError(reason instanceof Error ? reason.message : String(reason)); }
};
const converge = async (item: IdeationCandidate) => {
if (!selected) return; setError(undefined);
try { await ideationRequest(`/${encodeURIComponent(selected.id)}/converge`, projectId, { method: "POST", body: JSON.stringify({ candidateId: item.id }) }); await refresh(); }
catch (reason) { setError(reason instanceof Error ? reason.message : String(reason)); }
};
return <section className="ideation-panel" aria-label="Persisted ideation">
<header className="ideation-panel__header"><Lightbulb /><div><h2>Ideation</h2><p>Capture alternatives, then converge one into the Mission hierarchy.</p></div></header>
<form className="ideation-panel__form" onSubmit={submit}><input className="input" value={title} onChange={(event) => setTitle(event.target.value)} placeholder="Session title" aria-label="Session title" required /><button className="btn" type="submit">Start session</button></form>
{error && <p className="ideation-panel__error" role="alert">{error}</p>}
<div className="ideation-panel__body"><aside className="ideation-panel__sessions">{sessions.length ? sessions.map((session) => <button className={`card ideation-panel__session ${session.id === selected?.id ? "is-selected" : ""}`} type="button" onClick={() => setSelectedId(session.id)} key={session.id}>{session.title}<span>{session.status}</span></button>) : <p>No sessions yet.</p>}</aside>
<div className="ideation-panel__detail">{selected ? <><h3>{selected.title}</h3>{selected.targetMissionId && <p className="ideation-panel__handoff">Converged to Mission <strong>{selected.targetMissionId}</strong></p>}{selected.status === "open" && <form className="ideation-panel__form" onSubmit={addCandidate}><input className="input" value={candidate} onChange={(event) => setCandidate(event.target.value)} placeholder="Divergent candidate" aria-label="Divergent candidate" required /><button className="btn" type="submit">Add candidate</button></form>}<ul className="ideation-panel__candidates">{selected.candidates.map((item) => <li className="card" key={item.id}><p>{item.content}</p><small>{item.origin}{item.sourceRef ? ` · ${item.sourceRef}` : ""}</small>{selected.status === "open" && <button className="btn" type="button" onClick={() => void converge(item)}>Converge</button>}</li>)}</ul></> : <p>Select or start a session.</p>}</div></div>
</section>;
}