Files
fusion/plugins/fusion-plugin-compound-engineering/src/dashboard/hooks/useCeSession.ts
gsxdsm f251195ca2 feat(compound-engineering): CE flow renderer, stage launcher, audit (U6)
Extend the stage registry with presentation metadata (icon/label/glob) so adding
a stage stays data-only. Add CeFlow renderer for text/single_select/multi_select/
confirm questions over the U5 polling session routes, with a visibly-marked chat
fallback (AE1) that still completes the stage. Wire the artifact-hub launcher to
list and start registered stages. Add the skill-interaction audit test producing
a measured rich-vs-chat coverage ratio (declared classification: 8/8=100% across
brainstorm/ideate/plan), failing on unclassified interactions.
2026-06-02 19:34:01 -07:00

160 lines
5.1 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from "react";
import type { CeSession, CeSessionStatus } from "../../session/session-store.js";
import {
answerSession as answerSessionApi,
getSession as getSessionApi,
resumeSession as resumeSessionApi,
startSession as startSessionApi,
} from "./api.js";
/**
* Injectable transport so component tests can drive the lifecycle without a
* network. Defaults to the real polling routes.
*/
export interface CeSessionTransport {
start(stage: string, opts: { message?: string; projectId?: string }): Promise<CeSession>;
answer(sessionId: string, questionId: string, response: unknown): Promise<CeSession>;
resume(sessionId: string): Promise<CeSession>;
get(sessionId: string): Promise<CeSession>;
}
const defaultTransport: CeSessionTransport = {
start: (stage, opts) => startSessionApi(stage, opts),
answer: (id, qid, response) => answerSessionApi(id, qid, response),
resume: (id) => resumeSessionApi(id),
get: (id) => getSessionApi(id),
};
/** Statuses where no further polling is useful (settled or waiting on the user). */
const SETTLED: ReadonlySet<CeSessionStatus> = new Set([
"awaiting_input",
"completed",
"error",
"interrupted",
]);
export interface UseCeSessionOptions {
/** Poll interval (ms) while a turn is running (status active/launching). */
pollIntervalMs?: number;
transport?: CeSessionTransport;
}
export interface UseCeSessionResult {
session?: CeSession;
/** True while a request (start/answer/resume) is in flight. */
busy: boolean;
error?: string;
start(stage: string, opts?: { message?: string; projectId?: string }): Promise<void>;
answer(questionId: string, response: unknown): Promise<void>;
resume(): Promise<void>;
reset(): void;
}
/**
* Drive a single CE stage session through its lifecycle over the polling
* routes: start → (poll while a turn runs) → render question → submit answer →
* continue → completed/error; resume an interrupted/error session.
*
* The session routes already run one turn synchronously per request and return
* the post-turn state, so the common path settles immediately. Polling is the
* fallback for a session left `active`/`launching` (e.g. recovered from another
* process), honoring U5's client-polling transport.
*/
export function useCeSession(options: UseCeSessionOptions = {}): UseCeSessionResult {
const transport = options.transport ?? defaultTransport;
const pollIntervalMs = options.pollIntervalMs ?? 1500;
const [session, setSession] = useState<CeSession | undefined>();
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | undefined>();
// Keep the live id for the polling effect without re-subscribing on every
// session field change.
const sessionIdRef = useRef<string | undefined>(undefined);
const mounted = useRef(true);
useEffect(() => {
mounted.current = true;
return () => {
mounted.current = false;
};
}, []);
const apply = useCallback((next: CeSession) => {
sessionIdRef.current = next.id;
if (mounted.current) setSession(next);
}, []);
const run = useCallback(
async (op: () => Promise<CeSession>) => {
setBusy(true);
setError(undefined);
try {
const next = await op();
apply(next);
} catch (err) {
if (mounted.current) setError(err instanceof Error ? err.message : String(err));
} finally {
if (mounted.current) setBusy(false);
}
},
[apply],
);
const start = useCallback(
(stage: string, opts: { message?: string; projectId?: string } = {}) =>
run(() => transport.start(stage, opts)),
[run, transport],
);
const answer = useCallback(
(questionId: string, response: unknown) => {
const id = sessionIdRef.current;
if (!id) return Promise.resolve();
return run(() => transport.answer(id, questionId, response));
},
[run, transport],
);
const resume = useCallback(() => {
const id = sessionIdRef.current;
if (!id) return Promise.resolve();
return run(() => transport.resume(id));
}, [run, transport]);
const reset = useCallback(() => {
sessionIdRef.current = undefined;
setSession(undefined);
setError(undefined);
setBusy(false);
}, []);
// Poll while a turn is mid-flight (active/launching) and we are not already
// issuing a request. Stops as soon as the session settles.
const status = session?.status;
useEffect(() => {
const id = sessionIdRef.current;
if (!id || busy) return;
if (!status || SETTLED.has(status)) return;
let cancelled = false;
const timer = setInterval(() => {
transport
.get(id)
.then((next) => {
if (!cancelled) apply(next);
})
.catch((err: unknown) => {
if (!cancelled && mounted.current) {
setError(err instanceof Error ? err.message : String(err));
}
});
}, pollIntervalMs);
return () => {
cancelled = true;
clearInterval(timer);
};
}, [status, busy, transport, apply, pollIntervalMs]);
return { session, busy, error, start, answer, resume, reset };
}