Files
fusion/packages/dashboard/app/components/ResearchView.tsx
Fusion (runfusion.ai) 854045f430 feat(FN-5416): instrument SSE stream resume events across PR checks, dev-se
Adds structured stream-resume instrumentation to the dashboard across four SSE hooks (PR checks, dev-server logs, research, background sessions), wiring `[wake-trigger-diagnostics]` log markers and view-resume route guards so the engine can distinguish cold starts from resume paths; includes tests f

Fusion-Task-Id: FN-5416
2026-05-21 18:15:26 -07:00

507 lines
22 KiB
TypeScript

import { useEffect, useMemo, useState } from "react";
import { resolveResearchSettings, type Settings } from "@fusion/core";
import { Loader2, Search } from "lucide-react";
import { fetchAuthStatus, fetchSettings } from "../api";
import { useResearch } from "../hooks/useResearch";
import type { ResearchProviderOption } from "../research-types";
import { ResearchTaskActionModal } from "./ResearchTaskActionModal";
import type { SectionId } from "./SettingsModal";
import "./ResearchView.css";
import { recordResumeEvent } from "../utils/resumeInstrumentation";
interface ResearchViewProps {
projectId?: string;
addToast?: (message: string, type?: "success" | "error" | "info") => void;
onOpenSettings?: (section?: SectionId) => void;
readinessVersion?: number;
}
const DEFAULT_PROVIDERS: ResearchProviderOption[] = ["web-search", "page-fetch", "github", "local-docs", "llm-synthesis"];
const PROVIDER_TO_SOURCE_KEY: Record<ResearchProviderOption, keyof ReturnType<typeof resolveResearchSettings>["enabledSources"]> = {
"web-search": "webSearch",
"page-fetch": "pageFetch",
github: "github",
"local-docs": "localDocs",
"llm-synthesis": "llmSynthesis",
};
const PROVIDER_LABELS: Record<ResearchProviderOption, string> = {
"web-search": "Web Search",
"page-fetch": "Page Fetch",
github: "GitHub",
"local-docs": "Local Docs",
"llm-synthesis": "LLM Synthesis",
};
let researchViewWasPreviouslyInactive = false;
export function ResearchView({ projectId, addToast, onOpenSettings, readinessVersion = 0 }: ResearchViewProps) {
useEffect(() => {
recordResumeEvent({
view: "ResearchView",
trigger: researchViewWasPreviouslyInactive ? "route-active" : "remount",
projectId,
replayAttempted: false,
});
researchViewWasPreviouslyInactive = false;
return () => {
researchViewWasPreviouslyInactive = true;
recordResumeEvent({
view: "ResearchView",
trigger: "route-inactive",
projectId,
replayAttempted: false,
});
};
}, [projectId]);
const {
runs,
selectedRun,
selectedRunId,
setSelectedRunId,
availability,
loading,
error,
searchQuery,
setSearchQuery,
createRun,
cancelRun,
retryRun,
exportRun,
createTaskFromRun,
attachRunToTask,
statusCounts,
refresh,
uiError,
runActionState,
} = useResearch({ projectId });
const [query, setQuery] = useState("");
const [effectiveSettings, setEffectiveSettings] = useState(() => resolveResearchSettings(undefined));
const [authProviders, setAuthProviders] = useState<Array<{ id: string; authenticated: boolean }>>([]);
const [submitting, setSubmitting] = useState(false);
const [selectedProviders, setSelectedProviders] = useState<ResearchProviderOption[]>([]);
const [actionLoading, setActionLoading] = useState<string | null>(null);
const [modalState, setModalState] = useState<null | { mode: "create" | "enrich"; findingId: string }>(null);
const providerOptions = availability.supportedProviders ?? DEFAULT_PROVIDERS;
const selectedSearchProvider = effectiveSettings.searchProvider;
const isProviderEnabled = (provider: ResearchProviderOption) => {
if (provider === "web-search") {
return true;
}
return effectiveSettings.enabledSources[PROVIDER_TO_SOURCE_KEY[provider]];
};
useEffect(() => {
const enabledProviders = providerOptions.filter((provider) => isProviderEnabled(provider));
setSelectedProviders((current) => {
const currentEnabled = current.filter((provider) => enabledProviders.includes(provider));
if (currentEnabled.length > 0) {
return currentEnabled;
}
return enabledProviders;
});
}, [effectiveSettings.enabledSources, providerOptions]);
useEffect(() => {
let cancelled = false;
Promise.all([
fetchSettings(projectId) as Promise<Partial<Settings>>,
fetchAuthStatus().catch(() => ({ providers: [] })),
])
.then(([settings, authStatus]) => {
if (cancelled) return;
setEffectiveSettings(resolveResearchSettings(settings));
setAuthProviders(
authStatus.providers
.filter((provider) => provider.type === "api_key")
.map((provider) => ({ id: provider.id, authenticated: provider.authenticated })),
);
})
.catch(() => {
if (!cancelled) {
setEffectiveSettings(resolveResearchSettings(undefined));
}
});
return () => {
cancelled = true;
};
}, [projectId, readinessVersion]);
const statusLabel = useMemo(() => {
if (!selectedRun) return "No run selected";
return selectedRun.status;
}, [selectedRun]);
const statusDotClass = useMemo(() => {
if (!selectedRun) return "status-dot";
if (selectedRun.status === "queued" || selectedRun.status === "retry_waiting") return "status-dot status-dot--pending";
if (selectedRun.status === "running") return "status-dot status-dot--connecting";
if (selectedRun.status === "completed") return "status-dot status-dot--online";
if (selectedRun.status === "failed" || selectedRun.status === "cancelled") return "status-dot status-dot--error";
return "status-dot";
}, [selectedRun]);
const supportedExportFormats = availability.supportedExportFormats ?? ["markdown", "json", "html"];
const apiKeyProviderAuth = useMemo(() => new Map(authProviders.map((provider) => [provider.id, provider.authenticated])), [authProviders]);
const requiredCredentialProviders = useMemo(() => {
const required = new Set<string>();
if (effectiveSettings.enabledSources.webSearch && selectedSearchProvider) {
required.add(selectedSearchProvider);
}
if (effectiveSettings.enabledSources.llmSynthesis && effectiveSettings.synthesisProvider) {
required.add(effectiveSettings.synthesisProvider);
}
return [...required].filter((providerId) => apiKeyProviderAuth.has(providerId));
}, [effectiveSettings.enabledSources.llmSynthesis, effectiveSettings.enabledSources.webSearch, effectiveSettings.synthesisProvider, selectedSearchProvider, apiKeyProviderAuth]);
const missingCredentialProvider = requiredCredentialProviders.find((providerId) => apiKeyProviderAuth.get(providerId) !== true);
const setupState = useMemo(() => {
if (!availability.available) {
return {
reason: availability.reason ?? "Research is unavailable for this project.",
details: availability.setupInstructions,
settingsSection: "research-project" as SectionId,
};
}
if (!effectiveSettings.enabled) {
return {
reason: "Research is disabled for this project.",
details: "Enable project research settings to create runs.",
settingsSection: "research-project" as SectionId,
};
}
if (missingCredentialProvider) {
return {
reason: `Missing API key for ${missingCredentialProvider}.`,
details: "Add provider credentials in Authentication settings.",
settingsSection: "authentication" as SectionId,
};
}
return null;
}, [availability.available, availability.reason, availability.setupInstructions, effectiveSettings.enabled, missingCredentialProvider]);
const runAction = async (key: string, action: () => Promise<unknown>, successMessage: string) => {
setActionLoading(key);
try {
await action();
addToast?.(successMessage, "success");
await refresh();
} catch (err) {
addToast?.(err instanceof Error ? err.message : "Action failed", "error");
} finally {
setActionLoading(null);
}
};
const handleExport = async (format: "markdown" | "json" | "html") => {
if (!selectedRun) return;
setActionLoading(`export-${format}`);
try {
const payload = await exportRun(selectedRun.id, format);
const blob = new Blob([payload.content], { type: "text/plain;charset=utf-8" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = payload.filename;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
addToast?.(`Exported ${payload.filename}`, "success");
} catch (err) {
addToast?.(err instanceof Error ? err.message : "Export failed", "error");
} finally {
setActionLoading(null);
}
};
const handleCreateRun = async () => {
if (!query.trim()) return;
setSubmitting(true);
try {
const providers = selectedProviders.filter((provider) => isProviderEnabled(provider));
if (providers.length === 0) {
setSubmitting(false);
addToast?.("No enabled research sources are available for this project.", "error");
return;
}
const response = await createRun({ query: query.trim(), providers });
setSelectedRunId(response.run.id);
setQuery("");
addToast?.("Research run created", "success");
await refresh();
} catch (err) {
addToast?.(err instanceof Error ? err.message : "Failed to create run", "error");
} finally {
setSubmitting(false);
}
};
return (
<section className="research-view" aria-label="Research view">
<header className="research-view__header">
<div>
<h2 className="research-view__title">Research</h2>
<p className="research-view__subtitle">Cited search and synthesis runs: gather sources, fetch content, and synthesize findings.</p>
</div>
<div className="research-view__header-actions">
<button className="btn" type="button" onClick={() => void refresh()}>
Refresh
</button>
</div>
</header>
{setupState ? (
<div className="research-view__state research-view__state--error card" data-testid="research-state-unavailable">
<p>{setupState.reason}</p>
{setupState.details && <p>{setupState.details}</p>}
<p>
Current defaults: provider {effectiveSettings.searchProvider ?? "(not set)"}, max sources {effectiveSettings.limits.maxSourcesPerRun}
</p>
<div className="research-view__actions">
<button className="btn" type="button" onClick={() => void refresh()}>
Refresh
</button>
<button className="btn btn-primary" type="button" onClick={() => onOpenSettings?.(setupState.settingsSection)}>
Open Settings
</button>
</div>
</div>
) : (
<>
<div className="research-view__layout">
<aside className="research-view__sidebar card">
<div className="research-view__sidebar-content">
<div className="research-view__form">
<div className="form-group">
<label htmlFor="research-query">Query</label>
<textarea id="research-query" className="input research-view__textarea" value={query} onChange={(event) => setQuery(event.target.value)} />
</div>
<div className="form-group">
<label>Providers</label>
<div className="research-view__providers">
{providerOptions.map((provider) => {
const providerEnabled = isProviderEnabled(provider);
const providerLocked = provider === "web-search";
return (
<label key={provider} className="checkbox-label">
<input
type="checkbox"
checked={providerLocked || selectedProviders.includes(provider)}
disabled={providerLocked || !providerEnabled}
onChange={() => {
if (providerLocked || !providerEnabled) {
return;
}
setSelectedProviders((current) =>
current.includes(provider) ? current.filter((entry) => entry !== provider) : [...current, provider],
);
}}
/>
<span>
{PROVIDER_LABELS[provider] ?? provider}
{providerLocked ? <span className="research-view__provider-lock">Always on</span> : null}
</span>
</label>
);
})}
</div>
</div>
<button className="btn btn-primary" type="button" disabled={!query.trim() || submitting} onClick={() => void handleCreateRun()}>
{submitting ? <Loader2 className="animate-spin" size={14} /> : null}
Create Run
</button>
</div>
<div className="research-view__history-header form-group">
<label htmlFor="research-run-search">Search</label>
<div className="research-view__history-search-row">
<Search size={14} />
<input
id="research-run-search"
className="input"
placeholder="Search runs"
value={searchQuery}
onChange={(event) => setSearchQuery(event.target.value)}
/>
</div>
</div>
<div className="research-view__history" data-testid="research-state-running">
{runs.map((run) => (
<button
key={run.id}
type="button"
className={`research-view__history-item card${selectedRunId === run.id ? " research-view__history-item--active" : ""}`}
onClick={() => setSelectedRunId(run.id)}
>
<span className="card-id">{run.id}</span>
<span>{run.title}</span>
</button>
))}
</div>
</div>
</aside>
<div className="research-view__reader card">
{loading && <p data-testid="research-state-loading">Loading research runs</p>}
{!loading && error && <p data-testid="research-state-error">{error}</p>}
{!loading && !error && runs.length === 0 && <p data-testid="research-state-empty">No research runs yet</p>}
<div className="research-view__reader-content">
{selectedRun && (
<div className="research-view__run-detail">
<div className="research-view__status-row">
<span className={statusDotClass} />
<strong>{statusLabel}</strong>
</div>
<h3 className="research-view__run-title">{selectedRun.title}</h3>
<p className="research-view__run-query">{selectedRun.query}</p>
<p className="research-view__run-summary" data-testid="research-state-results">{selectedRun.results?.summary ?? "No summary yet."}</p>
<div className="research-view__actions">
<button
className="btn"
type="button"
title={!runActionState.cancelable ? runActionState.blockingReason : undefined}
disabled={actionLoading === "cancel" || actionLoading === "retry" || !runActionState.cancelable}
onClick={() => void runAction("cancel", () => cancelRun(selectedRun.id), "Run cancelled")}
>
Cancel
</button>
<button
className="btn"
type="button"
title={!runActionState.retryable ? runActionState.blockingReason : undefined}
disabled={actionLoading === "cancel" || actionLoading === "retry" || !runActionState.retryable}
onClick={() => void runAction("retry", () => retryRun(selectedRun.id), "Run retried")}
>
Retry
</button>
{supportedExportFormats.includes("markdown") && <button className="btn" type="button" disabled={actionLoading === "export-markdown"} onClick={() => void handleExport("markdown")}>Export MD</button>}
{supportedExportFormats.includes("json") && <button className="btn" type="button" disabled={actionLoading === "export-json"} onClick={() => void handleExport("json")}>Export JSON</button>}
{supportedExportFormats.includes("html") && <button className="btn" type="button" disabled={actionLoading === "export-html"} onClick={() => void handleExport("html")}>Export HTML</button>}
</div>
{selectedRun.error && <p className="research-view__error">{selectedRun.error}</p>}
{uiError && (
<div className="form-error" role="alert">
<p>{uiError.message}</p>
{uiError.setupHint && <p>{uiError.setupHint}</p>}
{uiError.code === "MISSING_CREDENTIALS" && (
<button className="btn btn-sm" type="button" onClick={() => onOpenSettings?.("authentication")}>
Open Authentication Settings
</button>
)}
{uiError.code === "FEATURE_DISABLED" && (
<button className="btn btn-sm" type="button" onClick={() => onOpenSettings?.("research-project")}>
Open Research Settings
</button>
)}
</div>
)}
{runActionState.blockingReason && (
<p className="research-view__run-query">{runActionState.blockingReason}</p>
)}
{Array.isArray(selectedRun.results?.findings) && selectedRun.results.findings.length > 0 && (
<div className="research-view__findings">
{selectedRun.results.findings.map((finding, index) => {
const findingRecord = finding as { id?: string };
const findingId = findingRecord.id?.trim() || `finding-${index + 1}`;
return (
<article key={findingId} className="research-view__finding card">
<h4>{finding.heading}</h4>
<p>{finding.content}</p>
<div className="research-view__actions research-view__finding-actions">
<button
className="btn btn-primary btn-sm"
type="button"
onClick={() => setModalState({ mode: "create", findingId })}
>
Create Task
</button>
<button
className="btn btn-sm"
type="button"
onClick={() => setModalState({ mode: "enrich", findingId })}
>
Enrich Task
</button>
</div>
</article>
);
})}
</div>
)}
{Array.isArray(selectedRun.results?.citations) && selectedRun.results!.citations!.length > 0 && (
<ul className="research-view__citations">
{selectedRun.results!.citations!.map((citation) => (
<li key={citation}><a href={citation} target="_blank" rel="noreferrer">{citation}</a></li>
))}
</ul>
)}
{selectedRun.events.length > 0 && (
<details>
<summary>Run history</summary>
<ul className="research-view__events">
{selectedRun.events.map((event) => (
<li key={event.id}>{event.message}</li>
))}
</ul>
</details>
)}
</div>
)}
{!selectedRun && runs.length > 0 && <p>Select a run to view details.</p>}
<div className="research-view__stats">
<div className="research-view__stat-card"><div className="research-view__stat-label">Running</div><div className="research-view__stat-value">{statusCounts.running}</div></div>
<div className="research-view__stat-card"><div className="research-view__stat-label">Completed</div><div className="research-view__stat-value">{statusCounts.completed}</div></div>
<div className="research-view__stat-card"><div className="research-view__stat-label">Failed</div><div className="research-view__stat-value">{statusCounts.failed}</div></div>
</div>
</div>
</div>
</div>
</>
)}
{selectedRun && modalState && (() => {
const findingIndex = selectedRun.results?.findings?.findIndex((entry, idx) => {
const findingRecord = entry as { id?: string };
const id = findingRecord.id?.trim() || `finding-${idx + 1}`;
return id === modalState.findingId;
}) ?? -1;
const finding = findingIndex >= 0 ? selectedRun.results!.findings[findingIndex] : null;
if (!finding) return null;
return (
<ResearchTaskActionModal
open
mode={modalState.mode}
run={selectedRun}
finding={{ id: modalState.findingId, heading: finding.heading, content: finding.content }}
projectId={projectId}
onClose={() => setModalState(null)}
onConfirm={async ({ taskId, title, description, priority, attachExport }) => {
if (modalState.mode === "create") {
await runAction(
"create-task",
() => createTaskFromRun(selectedRun.id, title, modalState.findingId, description, priority, attachExport),
"Task created from research",
);
} else if (taskId) {
await runAction(
"attach-task",
() => attachRunToTask(selectedRun.id, taskId, modalState.findingId, attachExport),
"Task enriched from research",
);
}
setModalState(null);
}}
/>
);
})()}
</section>
);
}