feat(FN-2998): scope ResearchView bottom safe-area padding to mobile

The merge brings in a fix (FN-2998) that scopes the bottom safe-area padding in ResearchView to mobile devices only, correcting an over-application of the padding on desktop or larger screens.

Fusion-Task-Id: FN-2998
This commit is contained in:
Fusion
2026-05-02 19:40:46 -07:00
committed by gsxdsm
parent 963e11e578
commit eed181ff80
18 changed files with 946 additions and 105 deletions

View File

@@ -173,6 +173,11 @@ describe("runTaskShow", () => {
{ sourceType: "github_import", sourceMetadata: { issueUrl: "https://github.com/owner/repo/issues/42" } },
"Source: GitHub Import (https://github.com/owner/repo/issues/42)",
],
[
{ sourceType: "research", sourceMetadata: { runId: "RR-001", findingLabel: "Latency hotspot" } },
"Source: Research (Latency hotspot)",
],
[{ sourceType: "research", sourceMetadata: { runId: "RR-002" } }, "Source: Research (RR-002)"],
] as const)("prints provenance line for %o", async (overrides, expectedLine) => {
mockTaskStoreGetTask(makeTask(overrides));

View File

@@ -24,6 +24,18 @@ function getGitHubIssueUrl(sourceMetadata: unknown): string | undefined {
return typeof issueUrl === "string" && issueUrl.length > 0 ? issueUrl : undefined;
}
function getResearchSourceContext(sourceMetadata: unknown): string | undefined {
if (!sourceMetadata || typeof sourceMetadata !== "object") return undefined;
const findingLabel = (sourceMetadata as { findingLabel?: unknown }).findingLabel;
if (typeof findingLabel === "string" && findingLabel.length > 0) {
return findingLabel;
}
const runId = (sourceMetadata as { runId?: unknown }).runId;
return typeof runId === "string" && runId.length > 0 ? runId : undefined;
}
function formatTaskSource(task: {
sourceType?: string;
sourceAgentId?: string;
@@ -49,6 +61,10 @@ function formatTaskSource(task: {
const issueUrl = getGitHubIssueUrl(task.sourceMetadata);
return issueUrl ? `GitHub Import (${issueUrl})` : "GitHub Import";
}
case "research": {
const context = getResearchSourceContext(task.sourceMetadata);
return context ? `Research (${context})` : "Research";
}
case "task_refine":
return task.sourceParentTaskId
? `Refinement of ${task.sourceParentTaskId}`

View File

@@ -30,7 +30,7 @@ import { join } from "node:path";
import { mkdtempSync, existsSync } from "node:fs";
import { tmpdir } from "node:os";
import * as projectMemory from "../project-memory.js";
import type { Task } from "../types.js";
import { buildResearchDocumentKey, type Task } from "../types.js";
function makeTmpDir(): string {
return mkdtempSync(join(tmpdir(), "kb-store-test-"));
@@ -7402,6 +7402,41 @@ Task with acceptance criteria
expect(fetched.sourceAgentId).toBe("agent-auto");
expect(fetched.sourceMetadata).toEqual({ trigger: "nightly" });
});
it("persists research provenance metadata", async () => {
const task = await store.createTask({
description: "Research finding follow-up",
source: {
sourceType: "research",
sourceMetadata: {
runId: "RR-42",
findingId: "finding-1",
findingLabel: "Key risk",
documentKey: "research-RR-42",
},
},
});
const fetched = await store.getTask(task.id);
expect(fetched.sourceType).toBe("research");
expect(fetched.sourceMetadata).toEqual({
runId: "RR-42",
findingId: "finding-1",
findingLabel: "Key risk",
documentKey: "research-RR-42",
});
});
});
describe("research document key helper", () => {
it("builds canonical research document keys", () => {
expect(buildResearchDocumentKey("RR-1")).toBe("research-RR-1");
expect(buildResearchDocumentKey("RR/1")).toBe("research-RR1");
});
it("rejects run IDs that sanitize to an empty string", () => {
expect(() => buildResearchDocumentKey("!!!")).toThrow("Invalid research run id");
});
});
// ── Title Handling Tests ────────────────────────────────────────

View File

@@ -1,4 +1,4 @@
export { COLUMNS, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, WORKFLOW_STEP_TEMPLATES, AGENT_PERMISSIONS, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, hasAgentIdentity, CheckoutConflictError, DEFAULT_HEARTBEAT_PROCEDURE_PATH, getDefaultHeartbeatProcedurePath, EXECUTION_MODES, DEFAULT_EXECUTION_MODE, TASK_PRIORITIES, DEFAULT_TASK_PRIORITY, validateMessageMetadata, normalizeMergeConflictStrategy } from "./types.js";
export { COLUMNS, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, WORKFLOW_STEP_TEMPLATES, AGENT_PERMISSIONS, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, hasAgentIdentity, CheckoutConflictError, DEFAULT_HEARTBEAT_PROCEDURE_PATH, getDefaultHeartbeatProcedurePath, EXECUTION_MODES, DEFAULT_EXECUTION_MODE, TASK_PRIORITIES, DEFAULT_TASK_PRIORITY, validateMessageMetadata, normalizeMergeConflictStrategy, buildResearchDocumentKey } from "./types.js";
export type { Column, IssueInfo, IssueState, TaskSourceIssue, PrInfo, PrStatus, Task, TaskTokenUsage, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, TaskCreateInput, TaskSource, SourceType, TaskDetail, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, MergeDetails, MergeResult, MergeConflictStrategy, CanonicalMergeConflictStrategy, Settings, GlobalSettings, ProjectSettings, WebSearchBackend, ResearchEnabledSources, ResearchGlobalDefaults, ResearchProjectLimits, ResearchProjectSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, ExecutionMode, TaskPriority, UnavailableNodePolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, NotificationEvent, NotificationPayload, NotificationProviderConfig, CustomProvider, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, MessageReplyReference, Mailbox, CheckoutLease, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter } from "./types.js";
export { AGENT_VALID_TRANSITIONS } from "./types.js";
export {

View File

@@ -4406,6 +4406,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
"image/gif",
"image/webp",
"text/plain",
"text/markdown",
"application/json",
"text/yaml",
"text/x-toml",

View File

@@ -741,6 +741,17 @@ export function validateDocumentKey(key: string): void {
}
}
/** Build canonical research enrichment document key from a run id. */
export function buildResearchDocumentKey(runId: string): string {
const sanitizedRunId = runId.replace(/[^A-Za-z0-9_-]/g, "");
if (!sanitizedRunId) {
throw new Error("Invalid research run id: sanitized run id is empty");
}
const key = `research-${sanitizedRunId}`;
validateDocumentKey(key);
return key;
}
export interface MergeDetails {
commitSha?: string;
filesChanged?: number;
@@ -813,6 +824,7 @@ export type SourceType =
| "cli"
| "api"
| "recovery"
| "research"
| "unknown";
/** Provenance metadata for how a task was created. */

View File

@@ -8128,28 +8128,40 @@ export function exportResearchRun(
export function createTaskFromResearchRun(
id: string,
input: { title?: string; includeSummary?: boolean; includeCitations?: boolean },
input: { findingId?: string; title?: string; description?: string; priority?: "low" | "normal" | "high" | "urgent"; attachExport?: boolean },
projectId?: string,
): Promise<{ task: { id: string; title: string } }> {
return api<{ task: { id: string; title: string } }>(
withProjectId(`/research/runs/${encodeURIComponent(id)}/create-task`, projectId),
): Promise<{ task: Task; documentKey: string; attachmentFilename?: string }> {
const findingId = input.findingId ?? "finding-1";
return api<{ task: Task; documentKey: string; attachmentFilename?: string }>(
withProjectId(`/research/runs/${encodeURIComponent(id)}/findings/${encodeURIComponent(findingId)}/task`, projectId),
{
method: "POST",
body: JSON.stringify(input),
body: JSON.stringify({
title: input.title,
description: input.description,
priority: input.priority,
attachExport: input.attachExport,
}),
},
);
}
export function attachResearchRunToTask(
id: string,
input: { taskId: string; mode: "document" | "attachment"; includeSummary?: boolean; includeCitations?: boolean },
input: { findingId?: string; taskId: string; attachExport?: boolean },
projectId?: string,
): Promise<{ task: { id: string }; documentKey?: string; attachmentName?: string }> {
return api<{ task: { id: string }; documentKey?: string; attachmentName?: string }>(
withProjectId(`/research/runs/${encodeURIComponent(id)}/attach-task`, projectId),
): Promise<{ taskId: string; documentKey: string; revision: number; attachmentFilename?: string }> {
const findingId = input.findingId ?? "finding-1";
return api<{ taskId: string; documentKey: string; revision: number; attachmentFilename?: string }>(
withProjectId(
`/research/runs/${encodeURIComponent(id)}/findings/${encodeURIComponent(findingId)}/tasks/${encodeURIComponent(input.taskId)}/enrich`,
projectId,
),
{
method: "POST",
body: JSON.stringify(input),
body: JSON.stringify({
attachExport: input.attachExport,
}),
},
);
}

View File

@@ -0,0 +1,37 @@
.research-task-action-modal__body {
display: flex;
flex-direction: column;
gap: var(--space-md);
}
.research-task-action-modal__preview {
display: flex;
flex-direction: column;
gap: var(--space-xs);
padding: var(--space-md);
}
.research-task-action-modal__preview p {
margin: 0;
}
.research-task-action-modal__field {
display: flex;
flex-direction: column;
gap: var(--space-xs);
color: var(--text-muted);
font-size: 0.75rem;
letter-spacing: 0.03em;
text-transform: uppercase;
}
.research-task-action-modal__textarea {
min-height: calc(var(--space-2xl) * 2);
resize: vertical;
}
@media (max-width: 768px) {
.research-task-action-modal {
width: min(100%, calc(100vw - (var(--space-md) * 2)));
}
}

View File

@@ -0,0 +1,130 @@
import { useEffect, useMemo, useState } from "react";
import type { Task, TaskPriority } from "@fusion/core";
import { fetchTasks } from "../api";
import type { ResearchRunDetail } from "../research-types";
import "./ResearchTaskActionModal.css";
type Mode = "create" | "enrich";
interface ResearchTaskActionModalProps {
open: boolean;
mode: Mode;
run: ResearchRunDetail;
finding: { id: string; heading?: string; content?: string };
projectId?: string;
onClose: () => void;
onConfirm: (payload: { taskId?: string; title?: string; description?: string; priority?: TaskPriority; attachExport: boolean }) => Promise<void>;
}
export function ResearchTaskActionModal({ open, mode, run, finding, projectId, onClose, onConfirm }: ResearchTaskActionModalProps) {
const [attachExport, setAttachExport] = useState(false);
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [priority, setPriority] = useState<TaskPriority>("normal");
const [taskId, setTaskId] = useState("");
const [tasks, setTasks] = useState<Task[]>([]);
const [loadingTasks, setLoadingTasks] = useState(false);
const [saving, setSaving] = useState(false);
const preview = useMemo(() => {
const firstSentence = (finding.content ?? "").split(/(?<=[.!?])\s+/)[0] ?? "";
return `${finding.heading || "Research finding"}${firstSentence}`.trim();
}, [finding.content, finding.heading]);
useEffect(() => {
if (!open) return;
setAttachExport(false);
setTitle(`Research: ${finding.heading || run.title}`);
setDescription(preview);
setPriority("normal");
setTaskId("");
if (mode === "enrich") {
setLoadingTasks(true);
void fetchTasks(50, 0, projectId)
.then((rows) => setTasks(rows.filter((task) => task.column !== "archived")))
.finally(() => setLoadingTasks(false));
}
}, [open, mode, projectId, finding.heading, preview, run.title]);
if (!open) return null;
return (
<div className="modal-overlay open" role="presentation" onClick={onClose}>
<div className="modal modal-lg research-task-action-modal" role="dialog" aria-modal="true" onClick={(event) => event.stopPropagation()}>
<div className="modal-header">
<h3>{mode === "create" ? "Create task from finding" : "Enrich existing task"}</h3>
<button className="modal-close" type="button" aria-label="Close" onClick={onClose}>×</button>
</div>
<div className="research-task-action-modal__body">
<div className="card research-task-action-modal__preview">
<p><strong>Run:</strong> {run.id}</p>
<p><strong>Finding:</strong> {finding.id}{finding.heading ? `${finding.heading}` : ""}</p>
<p>{preview || "No preview available."}</p>
</div>
{mode === "create" ? (
<>
<label className="research-task-action-modal__field">Title
<input className="input" value={title} onChange={(event) => setTitle(event.target.value)} />
</label>
<label className="research-task-action-modal__field">Description
<textarea className="input research-task-action-modal__textarea" value={description} onChange={(event) => setDescription(event.target.value)} />
</label>
<label className="research-task-action-modal__field">Priority
<select className="select" value={priority} onChange={(event) => setPriority(event.target.value as TaskPriority)}>
<option value="low">Low</option>
<option value="normal">Normal</option>
<option value="high">High</option>
<option value="urgent">Urgent</option>
</select>
</label>
</>
) : (
<label className="research-task-action-modal__field">Target task
<input
className="input"
list="research-task-action-task-list"
value={taskId}
placeholder={loadingTasks ? "Loading tasks…" : "Enter task ID"}
onChange={(event) => setTaskId(event.target.value)}
/>
<datalist id="research-task-action-task-list">
{tasks.map((task) => (
<option key={task.id} value={task.id}>{task.title}</option>
))}
</datalist>
</label>
)}
<label className="checkbox-label">
<input type="checkbox" checked={attachExport} onChange={(event) => setAttachExport(event.target.checked)} />
<span>Attach markdown export artifact</span>
</label>
</div>
<div className="modal-actions">
<button className="btn" type="button" onClick={onClose}>Cancel</button>
<button
className="btn btn-primary"
type="button"
disabled={saving || (mode === "enrich" && !taskId)}
onClick={() => {
setSaving(true);
void onConfirm({
taskId: mode === "enrich" ? taskId : undefined,
title: mode === "create" ? title.trim() : undefined,
description: mode === "create" ? description.trim() : undefined,
priority: mode === "create" ? priority : undefined,
attachExport,
}).finally(() => setSaving(false));
}}
>
{mode === "create" ? "Create Task" : "Enrich Task"}
</button>
</div>
</div>
</div>
);
}

View File

@@ -5,7 +5,7 @@
height: 100%;
min-height: 0;
padding: var(--space-lg);
padding-bottom: calc(var(--space-lg) + var(--mobile-nav-height) + env(safe-area-inset-bottom, 0px) + var(--standalone-bottom-gap));
padding-bottom: var(--space-lg);
}
.research-view__header {
@@ -77,10 +77,17 @@
gap: var(--space-xs);
}
.research-view__history-search-row .input {
flex: 1;
min-width: 0;
}
.research-view__history {
display: flex;
flex-direction: column;
gap: var(--space-xs);
flex: 1;
min-height: 0;
overflow: auto;
}
@@ -175,6 +182,10 @@
margin: 0;
}
.research-view__finding-actions {
margin-top: var(--space-sm);
}
.research-view__events {
margin: var(--space-sm) 0 0;
padding-left: var(--space-lg);

View File

@@ -4,6 +4,7 @@ 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";
@@ -57,8 +58,8 @@ export function ResearchView({ projectId, addToast, onOpenSettings, readinessVer
const [authProviders, setAuthProviders] = useState<Array<{ id: string; authenticated: boolean }>>([]);
const [submitting, setSubmitting] = useState(false);
const [selectedProviders, setSelectedProviders] = useState<ResearchProviderOption[]>([]);
const [taskIdToAttach, setTaskIdToAttach] = useState("");
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 isProviderEnabled = (provider: ResearchProviderOption) => effectiveSettings.enabledSources[PROVIDER_TO_SOURCE_KEY[provider]];
@@ -339,33 +340,35 @@ export function ResearchView({ projectId, addToast, onOpenSettings, readinessVer
{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>
<div className="research-view__actions">
<button className="btn btn-primary" type="button" disabled={actionLoading === "create-task"} onClick={() => void runAction("create-task", () => createTaskFromRun(selectedRun.id, `Research: ${selectedRun.title}`), "Task created from research") }>
Create Task
</button>
<div className="form-group">
<label htmlFor="research-task-id">Task ID</label>
<input
id="research-task-id"
className="input"
placeholder="Task ID"
value={taskIdToAttach}
onChange={(event) => setTaskIdToAttach(event.target.value)}
/>
</div>
<button className="btn" type="button" disabled={!taskIdToAttach.trim() || actionLoading === "attach-task"} onClick={() => void runAction("attach-task", () => attachRunToTask(selectedRun.id, taskIdToAttach.trim(), "document"), "Attached to task")}>
Attach to Task
</button>
</div>
{selectedRun.error && <p className="research-view__error">{selectedRun.error}</p>}
{Array.isArray(selectedRun.results?.findings) && selectedRun.results.findings.length > 0 && (
<div className="research-view__findings">
{selectedRun.results.findings.map((finding) => (
<article key={finding.heading} className="research-view__finding card">
<h4>{finding.heading}</h4>
<p>{finding.content}</p>
</article>
))}
{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 && (
@@ -397,6 +400,42 @@ export function ResearchView({ projectId, addToast, onOpenSettings, readinessVer
</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>
);
}

View File

@@ -239,6 +239,16 @@ function getIssueUrlFromMetadata(metadata: Task["sourceMetadata"]): string | und
return typeof issueUrl === "string" && issueUrl.length > 0 ? issueUrl : undefined;
}
function getResearchContextInfo(metadata: Task["sourceMetadata"]): string | undefined {
const findingLabel = metadata?.findingLabel;
if (typeof findingLabel === "string" && findingLabel.length > 0) {
return findingLabel;
}
const runId = metadata?.runId;
return typeof runId === "string" && runId.length > 0 ? runId : undefined;
}
const AgentDetailView = lazy(() => import("./AgentDetailView").then((m) => ({ default: m.AgentDetailView })));
function getProvenanceLabel(task: Task | TaskDetail, options: ProvenanceLabelOptions = {}): ProvenanceDisplay | null {
@@ -269,6 +279,13 @@ function getProvenanceLabel(task: Task | TaskDetail, options: ProvenanceLabelOpt
contextInfo: issueUrl,
};
}
case "research": {
const contextInfo = getResearchContextInfo(task.sourceMetadata);
return {
label: "Research",
contextInfo,
};
}
case "task_refine":
return {
label: "Refinement",

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { Header } from "../Header";
import { ResearchView } from "../ResearchView";
@@ -21,11 +21,13 @@ const configuredResearchSettings = {
const mockFetchSettings = vi.fn().mockResolvedValue(configuredResearchSettings);
const mockFetchAuthStatus = vi.fn().mockResolvedValue({ providers: [{ id: "openrouter", type: "api_key", authenticated: false }] });
const mockFetchTasks = vi.fn().mockResolvedValue([{ id: "FN-1", title: "Existing task", column: "todo" }]);
vi.mock("../../api", () => ({
fetchScripts: vi.fn().mockResolvedValue({}),
fetchSettings: (...args: unknown[]) => mockFetchSettings(...args),
fetchAuthStatus: (...args: unknown[]) => mockFetchAuthStatus(...args),
fetchTasks: (...args: unknown[]) => mockFetchTasks(...args),
}));
vi.mock("lucide-react", async (importOriginal) => {
@@ -142,7 +144,14 @@ describe("ResearchView", () => {
mockUseResearch.mockReturnValue({
...baseHookValue,
runs: [{ id: "RR-1", title: "t", query: "q", status: "pending" }],
selectedRun: { id: "RR-1", title: "t", query: "q", status: "pending", events: [{ id: "E-1", message: "queued" }], results: { summary: "Summary", findings: [], citations: [] } },
selectedRun: {
id: "RR-1",
title: "t",
query: "q",
status: "pending",
events: [{ id: "E-1", message: "queued" }],
results: { summary: "Summary", findings: [{ id: "finding-1", heading: "Finding", content: "Impact." }], citations: [] },
},
selectedRunId: "RR-1",
cancelRun,
retryRun,
@@ -157,16 +166,16 @@ describe("ResearchView", () => {
fireEvent.click(screen.getByText("Cancel"));
fireEvent.click(screen.getByText("Retry"));
fireEvent.click(screen.getByText("Create Task"));
fireEvent.change(screen.getByPlaceholderText("Task ID"), { target: { value: "FN-1" } });
fireEvent.click(screen.getByText("Attach to Task"));
fireEvent.click(screen.getByText("Export MD"));
fireEvent.click(screen.getAllByText("Create Task")[0]);
const createDialog = await screen.findByRole("dialog");
fireEvent.click(within(createDialog).getByRole("button", { name: "Create Task" }));
await waitFor(() => {
expect(cancelRun).toHaveBeenCalled();
expect(retryRun).toHaveBeenCalled();
expect(createTaskFromRun).toHaveBeenCalled();
expect(attachRunToTask).toHaveBeenCalled();
expect(exportRun).toHaveBeenCalled();
});
});

View File

@@ -203,6 +203,47 @@ describe("TaskDetailModal", () => {
expect(screen.getByText("Created via GitHub Import (https://github.com/owner/repo/issues/42)")).toBeInTheDocument();
});
it("renders finding label for research provenance", () => {
render(
<TaskDetailModal
task={makeTask({
sourceType: "research",
sourceMetadata: {
runId: "RR-123",
findingLabel: "Pricing pressure in EU segment",
},
})}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
expect(screen.getByText("Created via Research (Pricing pressure in EU segment)")).toBeInTheDocument();
});
it("falls back to run id for research provenance context", () => {
render(
<TaskDetailModal
task={makeTask({
sourceType: "research",
sourceMetadata: { runId: "RR-456" },
})}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
expect(screen.getByText("Created via Research (RR-456)")).toBeInTheDocument();
});
it.each(["unknown", undefined] as const)("omits provenance for %s source", (sourceType) => {
render(
<TaskDetailModal

View File

@@ -148,9 +148,16 @@ export function useResearch(options?: { projectId?: string }) {
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),
createTaskFromRun: (
runId: string,
title?: string,
findingId?: string,
description?: string,
priority?: "low" | "normal" | "high" | "urgent",
attachExport?: boolean,
) => createTaskFromResearchRun(runId, { title, findingId, description, priority, attachExport }, projectId),
attachRunToTask: (runId: string, taskId: string, findingId?: string, attachExport?: boolean) =>
attachResearchRunToTask(runId, { taskId, findingId, attachExport }, projectId),
statusCounts: runs.reduce<Record<ResearchRunStatus, number>>(
(acc, run) => {
acc[run.status] += 1;

View File

@@ -4,23 +4,46 @@ import express from "express";
import { get as performGet, request as performRequest } from "../test-request.js";
import { createResearchRouter } from "../research-routes.js";
function createMockStore() {
function createMockStore(options?: {
taskColumn?: string;
runId?: string;
missingRun?: boolean;
missingFinding?: boolean;
missingTask?: boolean;
existingAttachmentOriginalName?: string;
addAttachmentError?: string;
}) {
const run = {
id: "RR-1",
id: options?.runId ?? "RR-1",
query: "test",
topic: "test",
status: "pending",
sources: [],
events: [],
tags: [],
results: {
summary: "Run summary",
findings: options?.missingFinding
? []
: [
{
id: "finding-1",
heading: "Finding One",
content: "Important actionable result.",
sources: ["https://example.com/citation"],
},
],
},
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
let revision = 0;
const researchStore = {
listRuns: vi.fn(() => [run]),
createRun: vi.fn(() => run),
getRun: vi.fn(() => run),
getRun: vi.fn(() => (options?.missingRun ? null : run)),
updateStatus: vi.fn(),
updateRun: vi.fn(),
appendEvent: vi.fn(),
@@ -29,9 +52,25 @@ function createMockStore() {
return {
getResearchStore: () => researchStore,
createTask: vi.fn(async () => ({ id: "FN-1", title: "Task" })),
upsertTaskDocument: vi.fn(async () => ({ key: "research-rr-1" })),
addAttachment: vi.fn(async () => ({ filename: "RR-1.md" })),
createTask: vi.fn(async (input) => ({ id: "FN-1", title: input.title, description: input.description, attachments: [] })),
getTask: vi.fn(async (taskId: string) => {
if (options?.missingTask) return null;
return {
id: taskId,
column: options?.taskColumn ?? "todo",
attachments: options?.existingAttachmentOriginalName
? [{ filename: `123-${options.existingAttachmentOriginalName}`, originalName: options.existingAttachmentOriginalName }]
: [],
};
}),
upsertTaskDocument: vi.fn(async () => ({ key: "research-RR-1", revision: ++revision })),
addAttachment: vi.fn(async () => {
if (options?.addAttachmentError) {
throw new Error(options.addAttachmentError);
}
return { filename: "RR-1-finding-1.md" };
}),
log: vi.fn(async () => undefined),
};
}
@@ -47,7 +86,186 @@ describe("research-routes", () => {
expect(Array.isArray(response.body.runs)).toBe(true);
});
it("creates task from run", async () => {
it("creates task from finding with research provenance", async () => {
const store = createMockStore();
const app = express();
app.use(express.json());
app.use(createResearchRouter(store as any));
const response = await performRequest(
app,
"POST",
"/runs/RR-1/findings/finding-1/task",
JSON.stringify({ attachExport: true }),
{ "content-type": "application/json" },
);
expect(response.status).toBe(201);
expect(response.body.documentKey).toBe("research-RR-1");
expect(response.body.task.id).toBe("FN-1");
expect(store.addAttachment).toHaveBeenCalledWith(
"FN-1",
"RR-1-finding-1.md",
expect.any(Buffer),
"text/markdown",
);
expect(store.createTask).toHaveBeenCalledWith(
expect.objectContaining({
source: expect.objectContaining({
sourceType: "research",
sourceMetadata: expect.objectContaining({ runId: "RR-1", findingId: "finding-1" }),
}),
}),
);
});
it("enriches existing task from finding and returns revision", async () => {
const store = createMockStore();
const app = express();
app.use(express.json());
app.use(createResearchRouter(store as any));
const response = await performRequest(
app,
"POST",
"/runs/RR-1/findings/finding-1/tasks/FN-42/enrich",
JSON.stringify({ attachExport: false }),
{ "content-type": "application/json" },
);
expect(response.status).toBe(200);
expect(response.body.taskId).toBe("FN-42");
expect(response.body.documentKey).toBe("research-RR-1");
expect(response.body.revision).toBe(1);
});
it("skips duplicate attachment when original name already exists", async () => {
const store = createMockStore({ existingAttachmentOriginalName: "RR-1-finding-1.md" });
const app = express();
app.use(express.json());
app.use(createResearchRouter(store as any));
await performRequest(
app,
"POST",
"/runs/RR-1/findings/finding-1/tasks/FN-42/enrich",
JSON.stringify({ attachExport: true }),
{ "content-type": "application/json" },
);
expect(store.addAttachment).not.toHaveBeenCalled();
});
it("increments document revision on repeated enrichment", async () => {
const store = createMockStore();
const app = express();
app.use(express.json());
app.use(createResearchRouter(store as any));
const first = await performRequest(
app,
"POST",
"/runs/RR-1/findings/finding-1/tasks/FN-42/enrich",
JSON.stringify({ attachExport: false }),
{ "content-type": "application/json" },
);
const second = await performRequest(
app,
"POST",
"/runs/RR-1/findings/finding-1/tasks/FN-42/enrich",
JSON.stringify({ attachExport: false }),
{ "content-type": "application/json" },
);
expect(first.body.revision).toBe(1);
expect(second.body.revision).toBe(2);
});
it("returns 404 when run is missing", async () => {
const app = express();
app.use(express.json());
app.use(createResearchRouter(createMockStore({ missingRun: true }) as any));
const response = await performRequest(
app,
"POST",
"/runs/RR-1/findings/finding-1/task",
JSON.stringify({}),
{ "content-type": "application/json" },
);
expect(response.status).toBe(404);
expect(response.body.error).toContain("Run not found");
});
it("returns 404 when finding is missing", async () => {
const app = express();
app.use(express.json());
app.use(createResearchRouter(createMockStore({ missingFinding: true }) as any));
const response = await performRequest(
app,
"POST",
"/runs/RR-1/findings/finding-1/task",
JSON.stringify({}),
{ "content-type": "application/json" },
);
expect(response.status).toBe(404);
expect(response.body.error).toContain("Finding not found");
});
it("returns 404 when enrich target task is missing", async () => {
const app = express();
app.use(express.json());
app.use(createResearchRouter(createMockStore({ missingTask: true }) as any));
const response = await performRequest(
app,
"POST",
"/runs/RR-1/findings/finding-1/tasks/FN-42/enrich",
JSON.stringify({ attachExport: false }),
{ "content-type": "application/json" },
);
expect(response.status).toBe(404);
expect(response.body.error).toContain("Task not found");
});
it("returns 409 when enriching an archived task", async () => {
const app = express();
app.use(express.json());
app.use(createResearchRouter(createMockStore({ taskColumn: "archived" }) as any));
const response = await performRequest(
app,
"POST",
"/runs/RR-1/findings/finding-1/tasks/FN-42/enrich",
JSON.stringify({ attachExport: false }),
{ "content-type": "application/json" },
);
expect(response.status).toBe(409);
expect(response.body.error).toContain("Cannot enrich archived task");
});
it("returns 400 when run id sanitizes to an empty document key", async () => {
const app = express();
app.use(express.json());
app.use(createResearchRouter(createMockStore({ runId: "!!!" }) as any));
const response = await performRequest(
app,
"POST",
"/runs/!!!/findings/finding-1/task",
JSON.stringify({}),
{ "content-type": "application/json" },
);
expect(response.status).toBe(400);
expect(response.body.error).toContain("Invalid run id for research document key");
});
it("returns 400 when create payload priority is invalid", async () => {
const app = express();
app.use(express.json());
app.use(createResearchRouter(createMockStore() as any));
@@ -55,11 +273,81 @@ describe("research-routes", () => {
const response = await performRequest(
app,
"POST",
"/runs/RR-1/create-task",
JSON.stringify({ includeSummary: true, includeCitations: true }),
"/runs/RR-1/findings/finding-1/task",
JSON.stringify({ priority: "p0" }),
{ "content-type": "application/json" },
);
expect(response.status).toBe(200);
expect(response.body.task.id).toBe("FN-1");
expect(response.status).toBe(400);
expect(response.body.error).toContain("priority must be one of");
});
it("returns 400 when create payload attachExport is not boolean", async () => {
const app = express();
app.use(express.json());
app.use(createResearchRouter(createMockStore() as any));
const response = await performRequest(
app,
"POST",
"/runs/RR-1/findings/finding-1/task",
JSON.stringify({ attachExport: "yes" }),
{ "content-type": "application/json" },
);
expect(response.status).toBe(400);
expect(response.body.error).toContain("attachExport must be a boolean");
});
it("returns 400 when enrich payload attachExport is not boolean", async () => {
const app = express();
app.use(express.json());
app.use(createResearchRouter(createMockStore() as any));
const response = await performRequest(
app,
"POST",
"/runs/RR-1/findings/finding-1/tasks/FN-42/enrich",
JSON.stringify({ attachExport: "yes" }),
{ "content-type": "application/json" },
);
expect(response.status).toBe(400);
expect(response.body.error).toContain("attachExport must be a boolean");
});
it("returns 400 when attachment exceeds size limit", async () => {
const app = express();
app.use(express.json());
app.use(createResearchRouter(createMockStore({ addAttachmentError: "File too large: max 5242880 bytes" }) as any));
const response = await performRequest(
app,
"POST",
"/runs/RR-1/findings/finding-1/task",
JSON.stringify({ attachExport: true }),
{ "content-type": "application/json" },
);
expect(response.status).toBe(400);
expect(response.body.error).toContain("File too large");
});
it("returns 400 when attachment mime type is invalid", async () => {
const app = express();
app.use(express.json());
app.use(createResearchRouter(createMockStore({ addAttachmentError: "Invalid mime type: text/plain" }) as any));
const response = await performRequest(
app,
"POST",
"/runs/RR-1/findings/finding-1/task",
JSON.stringify({ attachExport: true }),
{ "content-type": "application/json" },
);
expect(response.status).toBe(400);
expect(response.body.error).toContain("Invalid mime type");
});
});

View File

@@ -7,6 +7,7 @@ import {
RESEARCH_SOURCE_TYPES,
RESEARCH_SOURCE_STATUSES,
RESEARCH_EVENT_TYPES,
buildResearchDocumentKey,
type ResearchRunListOptions,
type ResearchRunStatus,
} from "@fusion/core";
@@ -18,17 +19,6 @@ const DEFAULT_AVAILABILITY = {
supportedExportFormats: ["markdown", "json", "html"],
} as const;
function unavailableResponse(reason: string, code: "unavailable" | "not-configured" | "feature-disabled" = "unavailable") {
return {
availability: {
available: false,
code,
reason,
setupInstructions: "Enable the research subsystem and provider integrations to use this endpoint.",
},
};
}
function rethrowAsApiError(error: unknown, fallback = "Internal server error"): never {
if (error instanceof ApiError) throw error;
if (error instanceof Error) throw new ApiError(500, error.message);
@@ -60,6 +50,79 @@ function toRunDetail(run: ResearchRun) {
};
}
function getFindingId(finding: NonNullable<ResearchRun["results"]>["findings"][number], index: number): string {
const maybeFinding = finding as { id?: unknown };
const explicitId = typeof maybeFinding.id === "string" ? maybeFinding.id.trim() : "";
return explicitId || `finding-${index + 1}`;
}
function getFindingById(run: ResearchRun, findingId: string) {
const findings = run.results?.findings ?? [];
for (const [index, finding] of findings.entries()) {
if (getFindingId(finding, index) === findingId) {
return { finding, findingId };
}
}
return null;
}
function buildFindingTaskSummary(run: ResearchRun, finding: NonNullable<ResearchRun["results"]>["findings"][number]): string {
const heading = finding.heading?.trim() || "Research finding";
const content = finding.content?.trim() || "";
const firstSentence = content.split(/(?<=[.!?])\s+/)[0]?.trim() || content;
const scope = run.topic || run.query;
return `${heading}${firstSentence || "Review cited research details."}\n\nContext: ${scope}`;
}
function buildFindingMarkdown(run: ResearchRun, findingId: string, finding: NonNullable<ResearchRun["results"]>["findings"][number]): string {
const citations = (finding.sources ?? []).map((source) => `- ${source}`).join("\n");
const runSummary = run.results?.summary?.trim();
return [
`# Research Finding`,
``,
`- Run ID: ${run.id}`,
`- Finding ID: ${findingId}`,
`- Query: ${run.query}`,
``,
`## ${finding.heading || "Finding"}`,
finding.content || "",
runSummary ? `\n## Run Summary\n${runSummary}` : "",
citations ? `\n## Citations\n${citations}` : "",
]
.filter(Boolean)
.join("\n");
}
function validateAttachExport(value: unknown): boolean {
if (value === undefined) return false;
if (typeof value !== "boolean") {
throw badRequest("attachExport must be a boolean");
}
return value;
}
function isAttachmentValidationError(error: unknown): error is Error {
return error instanceof Error
&& (error.message.startsWith("Invalid mime type") || error.message.startsWith("File too large"));
}
async function addFindingAttachment(
scopedStore: TaskStore,
taskId: string,
filename: string,
markdown: string,
): Promise<string> {
try {
const attachment = await scopedStore.addAttachment(taskId, filename, Buffer.from(markdown, "utf8"), "text/markdown");
return attachment.filename;
} catch (error) {
if (isAttachmentValidationError(error)) {
throw badRequest(error.message);
}
throw error;
}
}
export function createResearchRouter(store: TaskStore): Router {
const router = Router();
const requestContext = new AsyncLocalStorage<TaskStore>();
@@ -184,58 +247,153 @@ export function createResearchRouter(store: TaskStore): Router {
}
});
router.post("/runs/:id/create-task", async (req, res) => {
router.post("/runs/:runId/findings/:findingId/task", async (req, res) => {
try {
const run = getStore().getRun(req.params.id);
if (!run) throw notFound(`Run not found: ${req.params.id}`);
const includeSummary = req.body?.includeSummary !== false;
const includeCitations = req.body?.includeCitations !== false;
const summary = includeSummary ? run.results?.summary ?? "" : "";
const citations = includeCitations ? (run.results?.citations ?? []).map((c) => `- ${c}`).join("\n") : "";
const description = [summary, citations].filter(Boolean).join("\n\n");
const scopedStore = requestContext.getStore();
if (!scopedStore) throw new ApiError(500, "Task store context unavailable");
const run = getStore().getRun(req.params.runId);
if (!run) throw notFound(`Run not found: ${req.params.runId}`);
const found = getFindingById(run, req.params.findingId);
if (!found) throw notFound(`Finding not found: ${req.params.findingId}`);
let documentKey: string;
try {
documentKey = buildResearchDocumentKey(req.params.runId);
} catch {
throw badRequest("Invalid run id for research document key");
}
const title = typeof req.body?.title === "string" && req.body.title.trim()
? req.body.title.trim()
: `Research: ${found.finding.heading || run.topic || run.query}`;
const description = typeof req.body?.description === "string" && req.body.description.trim()
? req.body.description.trim()
: buildFindingTaskSummary(run, found.finding);
const priority = req.body?.priority;
if (priority !== undefined && !["low", "normal", "high", "urgent"].includes(priority)) {
throw badRequest("priority must be one of: low, normal, high, urgent");
}
const attachExport = validateAttachExport(req.body?.attachExport);
const taskInput: TaskCreateInput = {
title: req.body?.title || `Research: ${run.topic || run.query}`,
title,
description,
priority,
source: {
sourceType: "research",
sourceRunId: run.id,
sourceMetadata: {
runId: run.id,
findingId: found.findingId,
findingLabel: found.finding.heading,
documentKey,
},
},
};
const task = await requestContext.getStore()!.createTask(taskInput);
res.json({ task: { id: task.id, title: task.title } });
const task = await scopedStore.createTask(taskInput);
const markdown = buildFindingMarkdown(run, found.findingId, found.finding);
await scopedStore.upsertTaskDocument(task.id, {
key: documentKey,
content: markdown,
author: "research",
metadata: {
runId: run.id,
findingId: found.findingId,
findingLabel: found.finding.heading,
},
});
if (typeof scopedStore.appendAgentLog === "function") {
await scopedStore.appendAgentLog(
task.id,
`Task created from research finding ${found.findingId} in run ${run.id}`,
"text",
"research-task-integration",
"executor",
);
}
let attachmentFilename: string | undefined;
if (attachExport) {
const filename = `${run.id}-${found.findingId}.md`;
const existing = await scopedStore.getTask(task.id);
if (!existing.attachments?.some((attachment) => attachment.originalName === filename)) {
attachmentFilename = await addFindingAttachment(scopedStore, task.id, filename, markdown);
}
}
const responseTask = await scopedStore.getTask(task.id);
res.status(201).json({ task: responseTask, documentKey, attachmentFilename });
} catch (error) {
rethrowAsApiError(error, "Failed to create task from research run");
if (error instanceof ApiError) {
res.status(error.statusCode).json({ error: error.message });
return;
}
const message = error instanceof Error ? error.message : "Failed to create task from research finding";
res.status(500).json({ error: message });
}
});
router.post("/runs/:id/attach-task", async (req, res) => {
router.post("/runs/:runId/findings/:findingId/tasks/:taskId/enrich", async (req, res) => {
try {
const scopedStore = requestContext.getStore();
if (!scopedStore) {
res.status(501).json(unavailableResponse("Task store context unavailable"));
return;
if (!scopedStore) throw new ApiError(500, "Task store context unavailable");
const run = getStore().getRun(req.params.runId);
if (!run) throw notFound(`Run not found: ${req.params.runId}`);
const found = getFindingById(run, req.params.findingId);
if (!found) throw notFound(`Finding not found: ${req.params.findingId}`);
const task = await scopedStore.getTask(req.params.taskId);
if (!task) throw notFound(`Task not found: ${req.params.taskId}`);
if (task.column === "archived") throw new ApiError(409, "Cannot enrich archived task");
let documentKey: string;
try {
documentKey = buildResearchDocumentKey(req.params.runId);
} catch {
throw badRequest("Invalid run id for research document key");
}
const run = getStore().getRun(req.params.id);
if (!run) throw notFound(`Run not found: ${req.params.id}`);
const taskId = String(req.body?.taskId ?? "").trim();
const mode = req.body?.mode;
if (!taskId) throw badRequest("taskId is required");
if (mode !== "document" && mode !== "attachment") throw badRequest("mode must be 'document' or 'attachment'");
const markdown = buildFindingMarkdown(run, found.findingId, found.finding);
const document = await scopedStore.upsertTaskDocument(task.id, {
key: documentKey,
content: markdown,
author: "research",
metadata: {
runId: run.id,
findingId: found.findingId,
findingLabel: found.finding.heading,
},
});
const markdown = `# Research Findings\n\n## Query\n${run.query}\n\n## Summary\n${run.results?.summary ?? ""}\n\n## Citations\n${(run.results?.citations ?? []).map((c) => `- ${c}`).join("\n")}`;
if (mode === "document") {
const document = await scopedStore.upsertTaskDocument(taskId, { key: `research-${run.id.toLowerCase()}`, content: markdown });
res.json({ task: { id: taskId }, documentKey: document.key });
return;
const attachExport = validateAttachExport(req.body?.attachExport);
let attachmentFilename: string | undefined;
if (attachExport) {
const filename = `${run.id}-${found.findingId}.md`;
if (!task.attachments?.some((attachment) => attachment.originalName === filename)) {
attachmentFilename = await addFindingAttachment(scopedStore, task.id, filename, markdown);
}
}
const attachment = await scopedStore.addAttachment(
taskId,
`${run.id}.txt`,
Buffer.from(markdown, "utf8"),
"text/plain",
);
res.json({ task: { id: taskId }, attachmentName: attachment.filename });
if (typeof scopedStore.appendAgentLog === "function") {
await scopedStore.appendAgentLog(
task.id,
`Task enriched from research finding ${found.findingId} in run ${run.id}`,
"text",
"research-task-integration",
"executor",
);
}
res.json({ taskId: task.id, documentKey, revision: document.revision, attachmentFilename });
} catch (error) {
rethrowAsApiError(error, "Failed to attach research findings to task");
if (error instanceof ApiError) {
res.status(error.statusCode).json({ error: error.message });
return;
}
const message = error instanceof Error ? error.message : "Failed to enrich task from research finding";
res.status(500).json({ error: message });
}
});