Commits merged: - fix(FN-2994): gate unavailable research workflow and humanize provider labels - fix(FN-2994): polish research view form and history interactions - feat(FN-2994): complete Step 5 — document research view architecture - fix(FN-2994): align status-dot variant and hook/test conventions - fix(FN-2994): address lint and typecheck issues - test(FN-2994): cover research lifecycle actions and pending status - fix(FN-2994): polish research actions and cited reader behavior - feat(FN-2994): complete Step 3 — add lifecycle actions and task flows - fix(FN-2994): ensure research enables header overflow gate - feat(FN-2994): complete Step 2 — implement research view shell layout - feat(FN-2994): complete Step 2 — gate research navigation and view - feat(FN-2994): complete Step 1 — add research api surface and hook Files changed: docs/architecture.md | 7 +- docs/dashboard-guide.md | 21 ++ packages/dashboard/app/App.tsx | 11 +- packages/dashboard/app/api/legacy.ts | 143 ++++----- packages/dashboard/app/components/Header.tsx | 29 +- packages/dashboard/app/components/MobileNavBar.tsx | 22 +- packages/dashboard/app/components/ResearchView.css | 247 +++++++++------ packages/dashboard/app/components/ResearchView.tsx | 347 ++++++++++++++------- .../app/components/__tests__/ResearchView.test.tsx | 183 ++++++----- .../app/hooks/__tests__/useResearch.test.ts | 66 ++++ packages/dashboard/app/hooks/useResearch.ts | 162 ++++++++++ packages/dashboard/app/research-types.ts | 43 +++ packages/dashboard/app/styles.css | 4 + .../src/__tests__/research-routes.test.ts | 65 ++++ packages/dashboard/src/research-routes.ts | 195 ++++++++++-- 15 files changed, 1111 insertions(+), 434 deletions(-) Fusion-Task-Id: FN-2994
163 lines
5.4 KiB
TypeScript
163 lines
5.4 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import type { ResearchRunStatus } from "@fusion/core";
|
|
import {
|
|
attachResearchRunToTask,
|
|
cancelResearchRun,
|
|
createResearchRun,
|
|
createTaskFromResearchRun,
|
|
exportResearchRun,
|
|
getResearchRun,
|
|
listResearchRuns,
|
|
retryResearchRun,
|
|
type CreateResearchRunInput,
|
|
} from "../api";
|
|
import { subscribeSse } from "../sse-bus";
|
|
import type { ResearchAvailability, ResearchRunDetail, ResearchRunListItem } from "../research-types";
|
|
|
|
const SEARCH_DEBOUNCE_MS = 300;
|
|
const POLL_INTERVAL_MS = 4000;
|
|
|
|
export function useResearch(options?: { projectId?: string }) {
|
|
const projectId = options?.projectId;
|
|
const [runs, setRuns] = useState<ResearchRunListItem[]>([]);
|
|
const [selectedRunId, setSelectedRunId] = useState<string | null>(null);
|
|
const [selectedRun, setSelectedRun] = useState<ResearchRunDetail | null>(null);
|
|
const [availability, setAvailability] = useState<ResearchAvailability>({ available: true });
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [searchQuery, setSearchQuery] = useState("");
|
|
const fetchVersionRef = useRef(0);
|
|
const projectContextVersionRef = useRef(0);
|
|
const previousProjectIdRef = useRef<string | undefined>(projectId);
|
|
|
|
useEffect(() => {
|
|
if (previousProjectIdRef.current !== projectId) {
|
|
previousProjectIdRef.current = projectId;
|
|
projectContextVersionRef.current++;
|
|
}
|
|
}, [projectId]);
|
|
|
|
const refreshRuns = useCallback(async (query = searchQuery) => {
|
|
const requestVersion = ++fetchVersionRef.current;
|
|
const requestProjectId = projectId;
|
|
|
|
setError(null);
|
|
|
|
try {
|
|
const response = await listResearchRuns({ q: query || undefined, limit: 100 }, requestProjectId);
|
|
if (requestVersion !== fetchVersionRef.current || requestProjectId !== projectId) return;
|
|
setRuns(response.runs);
|
|
setAvailability(response.availability);
|
|
if (selectedRunId && !response.runs.some((run) => run.id === selectedRunId)) {
|
|
setSelectedRunId(null);
|
|
setSelectedRun(null);
|
|
}
|
|
} catch (err) {
|
|
if (requestVersion !== fetchVersionRef.current || requestProjectId !== projectId) return;
|
|
setError(err instanceof Error ? err.message : "Failed to load research runs");
|
|
} finally {
|
|
if (requestVersion === fetchVersionRef.current) {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
}, [projectId, searchQuery, selectedRunId]);
|
|
|
|
const loadRun = useCallback(async (runId: string) => {
|
|
const response = await getResearchRun(runId, projectId);
|
|
setSelectedRun(response.run);
|
|
setAvailability(response.availability);
|
|
return response.run;
|
|
}, [projectId]);
|
|
|
|
useEffect(() => {
|
|
setLoading(true);
|
|
const timer = window.setTimeout(() => {
|
|
void refreshRuns(searchQuery);
|
|
}, SEARCH_DEBOUNCE_MS);
|
|
return () => window.clearTimeout(timer);
|
|
}, [refreshRuns, searchQuery]);
|
|
|
|
useEffect(() => {
|
|
if (!selectedRunId) {
|
|
setSelectedRun(null);
|
|
return;
|
|
}
|
|
void loadRun(selectedRunId);
|
|
}, [loadRun, selectedRunId]);
|
|
|
|
useEffect(() => {
|
|
const contextVersionAtStart = projectContextVersionRef.current;
|
|
const isStale = () => projectContextVersionRef.current !== contextVersionAtStart;
|
|
const query = projectId ? `?projectId=${encodeURIComponent(projectId)}` : "";
|
|
|
|
let active = true;
|
|
const refreshIfActive = () => {
|
|
if (!active || isStale()) return;
|
|
void refreshRuns();
|
|
if (selectedRunId) {
|
|
void loadRun(selectedRunId);
|
|
}
|
|
};
|
|
|
|
const unsubscribe = subscribeSse(`/api/events${query}`, {
|
|
events: {
|
|
"research:run:created": refreshIfActive,
|
|
"research:run:updated": refreshIfActive,
|
|
"research:run:completed": refreshIfActive,
|
|
"research:run:failed": refreshIfActive,
|
|
"research:run:cancelled": refreshIfActive,
|
|
},
|
|
onReconnect: refreshIfActive,
|
|
});
|
|
|
|
const pollTimer = window.setInterval(refreshIfActive, POLL_INTERVAL_MS);
|
|
|
|
return () => {
|
|
active = false;
|
|
unsubscribe();
|
|
window.clearInterval(pollTimer);
|
|
};
|
|
}, [projectId, refreshRuns, selectedRunId, loadRun]);
|
|
|
|
return {
|
|
runs,
|
|
selectedRun,
|
|
selectedRunId,
|
|
setSelectedRunId,
|
|
availability,
|
|
loading,
|
|
error,
|
|
searchQuery,
|
|
setSearchQuery,
|
|
refresh: refreshRuns,
|
|
createRun: (input: CreateResearchRunInput) => createResearchRun(input, projectId),
|
|
cancelRun: async (runId: string) => {
|
|
const response = await cancelResearchRun(runId, projectId);
|
|
if (selectedRunId === runId) {
|
|
setSelectedRun(response.run);
|
|
}
|
|
await refreshRuns();
|
|
return response;
|
|
},
|
|
retryRun: async (runId: string) => {
|
|
const response = await retryResearchRun(runId, projectId);
|
|
if (selectedRunId === runId) {
|
|
setSelectedRun(response.run);
|
|
}
|
|
await refreshRuns();
|
|
return response;
|
|
},
|
|
exportRun: (runId: string, format: "markdown" | "json" | "html") => exportResearchRun(runId, format, projectId),
|
|
createTaskFromRun: (runId: string, title?: string) => createTaskFromResearchRun(runId, { title }, projectId),
|
|
attachRunToTask: (runId: string, taskId: string, mode: "document" | "attachment") =>
|
|
attachResearchRunToTask(runId, { taskId, mode }, projectId),
|
|
statusCounts: runs.reduce<Record<ResearchRunStatus, number>>(
|
|
(acc, run) => {
|
|
acc[run.status] += 1;
|
|
return acc;
|
|
},
|
|
{ pending: 0, running: 0, completed: 0, failed: 0, cancelled: 0 },
|
|
),
|
|
};
|
|
}
|