Files
fusion/packages/dashboard/app/hooks/useResearch.ts
Fusion c5f35ab349 feat(FN-2994): merge fusion/fn-2994
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
2026-04-30 03:43:21 -07:00

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 },
),
};
}