feat(FN-1852): merge fusion/fn-1852

This commit is contained in:
gsxdsm
2026-04-14 18:46:00 -07:00
parent 4a91421ee4
commit 301bfbe3d7
4 changed files with 240 additions and 40 deletions

View File

@@ -368,6 +368,33 @@ export function TaskDetailModal({
return () => { cancelled = true; }; return () => { cancelled = true; };
}, [activeTab, task.id, projectId, addToast]); }, [activeTab, task.id, projectId, addToast]);
// Subscribe to SSE for real-time workflow result updates while workflow tab is active
useEffect(() => {
if (activeTab !== "workflow") return;
const query = projectId ? `?projectId=${encodeURIComponent(projectId)}` : "";
const es = new EventSource(`/api/events${query}`);
const handleTaskUpdated = (e: MessageEvent) => {
try {
const updatedTask = JSON.parse(e.data);
// Only update if this is for our task and has workflow step results
if (updatedTask.id === task.id && Array.isArray(updatedTask.workflowStepResults)) {
setWorkflowResults(updatedTask.workflowStepResults);
}
} catch {
// Skip malformed events
}
};
es.addEventListener("task:updated", handleTaskUpdated);
return () => {
es.removeEventListener("task:updated", handleTaskUpdated);
es.close();
};
}, [activeTab, task.id, projectId]);
// Reset dependency search when dropdown closes // Reset dependency search when dropdown closes
useEffect(() => { useEffect(() => {
if (!showDepDropdown) { if (!showDepDropdown) {
@@ -1136,6 +1163,7 @@ export function TaskDetailModal({
enabledWorkflowSteps={workflowEnabledSteps} enabledWorkflowSteps={workflowEnabledSteps}
canEdit={canEdit} canEdit={canEdit}
projectId={projectId} projectId={projectId}
isTaskInProgress={task.column === "in-progress" && task.status !== "paused"}
onWorkflowStepsChange={handleWorkflowStepsChange} onWorkflowStepsChange={handleWorkflowStepsChange}
/> />
</div> </div>

View File

@@ -1,9 +1,10 @@
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react"; import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { Check, ChevronDown, ChevronUp, Maximize2, Pencil, X } from "lucide-react"; import { Check, ChevronDown, ChevronUp, Maximize2, Pencil, X } from "lucide-react";
import ReactMarkdown from "react-markdown"; import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm"; import remarkGfm from "remark-gfm";
import type { WorkflowStep, WorkflowStepResult } from "@fusion/core"; import type { AgentLogEntry, WorkflowStep, WorkflowStepResult } from "@fusion/core";
import { fetchWorkflowSteps } from "../api"; import { fetchWorkflowSteps } from "../api";
import { useAgentLogs } from "../hooks/useAgentLogs";
import type { Components } from "react-markdown"; import type { Components } from "react-markdown";
// Markdown rendering components for workflow output // Markdown rendering components for workflow output
@@ -42,6 +43,7 @@ interface WorkflowResultsTabProps {
enabledWorkflowSteps?: string[]; enabledWorkflowSteps?: string[];
canEdit?: boolean; canEdit?: boolean;
projectId?: string; projectId?: string;
isTaskInProgress?: boolean;
onWorkflowStepsChange?: (steps: string[]) => void; onWorkflowStepsChange?: (steps: string[]) => void;
} }
@@ -105,6 +107,92 @@ function phaseBadge(phase: "pre-merge" | "post-merge", id: string, prefix: strin
); );
} }
/**
* Renders live agent log output for a running (pending) workflow step.
* Filters entries to show only those timestamped on or after the step's startedAt.
*/
function LiveAgentLogOutput({
entries,
startedAt,
stepId,
}: {
entries: AgentLogEntry[];
startedAt: string;
stepId: string;
}) {
const containerRef = useRef<HTMLDivElement>(null);
const startedAtMs = new Date(startedAt).getTime();
// Filter entries to only show those from this step's time window
const stepEntries = entries.filter((entry) => {
const entryMs = new Date(entry.timestamp).getTime();
return entryMs >= startedAtMs;
});
// Auto-scroll to bottom as new entries arrive
useEffect(() => {
const container = containerRef.current;
if (!container) return;
container.scrollTop = container.scrollHeight;
}, [stepEntries.length]);
if (entries.length === 0) {
return (
<div className="workflow-live-log" data-testid={`workflow-live-log-${stepId}`}>
<div className="workflow-live-log-empty">Waiting for agent output</div>
</div>
);
}
return (
<div
ref={containerRef}
className="workflow-live-log"
data-testid={`workflow-live-log-${stepId}`}
>
{stepEntries.map((entry, i) => {
if (entry.type === "tool") {
return (
<div key={i} className="workflow-live-log-tool">
{entry.text}
{entry.detail && <span className="workflow-live-log-detail"> {entry.detail}</span>}
</div>
);
}
if (entry.type === "tool_result") {
return (
<div key={i} className="workflow-live-log-tool-result">
{entry.text}
{entry.detail && <span className="workflow-live-log-detail"> {entry.detail}</span>}
</div>
);
}
if (entry.type === "tool_error") {
return (
<div key={i} className="workflow-live-log-tool-error">
{entry.text}
{entry.detail && <span className="workflow-live-log-detail"> {entry.detail}</span>}
</div>
);
}
if (entry.type === "thinking") {
return (
<div key={i} className="workflow-live-log-thinking">
{entry.text}
</div>
);
}
// Default: text entries
return (
<span key={i} className="workflow-live-log-text">
{entry.text}
</span>
);
})}
</div>
);
}
export function WorkflowResultsTab({ export function WorkflowResultsTab({
taskId, taskId,
results, results,
@@ -112,6 +200,7 @@ export function WorkflowResultsTab({
enabledWorkflowSteps, enabledWorkflowSteps,
canEdit, canEdit,
projectId, projectId,
isTaskInProgress,
onWorkflowStepsChange, onWorkflowStepsChange,
}: WorkflowResultsTabProps) { }: WorkflowResultsTabProps) {
const [expandedOutputs, setExpandedOutputs] = useState<Record<string, boolean>>({}); const [expandedOutputs, setExpandedOutputs] = useState<Record<string, boolean>>({});
@@ -120,6 +209,16 @@ export function WorkflowResultsTab({
const [allWorkflowSteps, setAllWorkflowSteps] = useState<WorkflowStep[]>([]); const [allWorkflowSteps, setAllWorkflowSteps] = useState<WorkflowStep[]>([]);
const [isEditing, setIsEditing] = useState(false); const [isEditing, setIsEditing] = useState(false);
// Check if any result has pending status
const hasPendingStep = results.some((r) => r.status === "pending");
// Subscribe to live agent logs when task is in progress and has a pending step
const { entries: liveLogEntries } = useAgentLogs(
taskId,
!!isTaskInProgress && hasPendingStep,
projectId,
);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
fetchWorkflowSteps(projectId) fetchWorkflowSteps(projectId)
@@ -412,7 +511,14 @@ export function WorkflowResultsTab({
)} )}
</div> </div>
{result.output && ( {/* Show live agent logs for pending steps, static output for completed steps */}
{result.status === "pending" && result.startedAt ? (
<LiveAgentLogOutput
entries={liveLogEntries}
startedAt={result.startedAt}
stepId={result.workflowStepId}
/>
) : result.output ? (
<div className="workflow-result-output-section"> <div className="workflow-result-output-section">
<div className="workflow-result-output-header"> <div className="workflow-result-output-header">
<span className="workflow-result-output-label">Output:</span> <span className="workflow-result-output-label">Output:</span>
@@ -471,7 +577,7 @@ export function WorkflowResultsTab({
</div> </div>
)} )}
</div> </div>
)} ) : null}
</div> </div>
); );
})} })}

View File

@@ -22729,6 +22729,58 @@ html .column.drag-over * {
overflow-y: auto; overflow-y: auto;
} }
/* ===== Workflow Live Log ===== */
.workflow-live-log {
margin-top: var(--space-sm, 8px);
padding: var(--space-sm, 8px);
background: var(--surface-elevated, #161b22);
border: 1px solid var(--border, #30363d);
border-radius: var(--radius-sm, 4px);
max-height: 200px;
overflow-y: auto;
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
font-size: 12px;
line-height: 1.5;
}
.workflow-live-log-empty {
color: var(--text-muted, #8b949e);
font-style: italic;
}
.workflow-live-log-text {
display: block;
white-space: pre-wrap;
word-break: break-word;
color: var(--text, #e6edf3);
}
.workflow-live-log-tool {
color: var(--accent, #58a6ff);
padding: 2px 0;
}
.workflow-live-log-tool-result {
color: var(--success, #3fb950);
padding: 2px 0 2px 8px;
}
.workflow-live-log-tool-error {
color: var(--error, #f85149);
padding: 2px 0 2px 8px;
}
.workflow-live-log-thinking {
color: var(--text-muted, #8b949e);
font-style: italic;
padding: 2px 0;
}
.workflow-live-log-detail {
color: var(--text-muted, #8b949e);
}
/* ===== Model Onboarding Modal ===== */ /* ===== Model Onboarding Modal ===== */
.model-onboarding-modal { .model-onboarding-modal {

View File

@@ -2896,6 +2896,16 @@ ${failureFeedback}
const startedAt = new Date().toISOString(); const startedAt = new Date().toISOString();
// Push pending entry BEFORE execution so dashboard can show live status
results.push({
workflowStepId: ws.id,
workflowStepName: ws.name,
phase: stepPhase,
status: "pending",
startedAt,
});
await this.store.updateTask(task.id, { workflowStepResults: results });
try { try {
const result: WorkflowStepOutcome = stepMode === "script" const result: WorkflowStepOutcome = stepMode === "script"
? await this.executeScriptWorkflowStep(task, ws, worktreePath, settings) ? await this.executeScriptWorkflowStep(task, ws, worktreePath, settings)
@@ -2905,15 +2915,16 @@ ${failureFeedback}
if (result.success) { if (result.success) {
await this.store.logEntry(task.id, `[pre-merge] Workflow step completed: ${ws.name}`); await this.store.logEntry(task.id, `[pre-merge] Workflow step completed: ${ws.name}`);
executorLog.log(`${task.id} — [pre-merge] workflow step passed: ${ws.name}`); executorLog.log(`${task.id} — [pre-merge] workflow step passed: ${ws.name}`);
results.push({ // Update existing pending entry in place
workflowStepId: ws.id, const existingIdx = results.findIndex(r => r.workflowStepId === ws.id);
workflowStepName: ws.name, if (existingIdx >= 0) {
phase: stepPhase, results[existingIdx] = {
status: "passed", ...results[existingIdx],
output: result.output, status: "passed",
startedAt, output: result.output,
completedAt, completedAt,
}); };
}
await this.store.updateTask(task.id, { workflowStepResults: results }); await this.store.updateTask(task.id, { workflowStepResults: results });
} else if (result.revisionRequested) { } else if (result.revisionRequested) {
// Revision requested — this is a structured outcome that routes back to executor // Revision requested — this is a structured outcome that routes back to executor
@@ -2923,15 +2934,16 @@ ${failureFeedback}
result.output, result.output,
); );
executorLog.log(`${task.id} — [pre-merge] workflow step requested revision: ${ws.name}`); executorLog.log(`${task.id} — [pre-merge] workflow step requested revision: ${ws.name}`);
results.push({ // Update existing pending entry in place
workflowStepId: ws.id, const existingIdx = results.findIndex(r => r.workflowStepId === ws.id);
workflowStepName: ws.name, if (existingIdx >= 0) {
phase: stepPhase, results[existingIdx] = {
status: "failed", ...results[existingIdx],
output: result.output || "Revision requested", status: "failed",
startedAt, output: result.output || "Revision requested",
completedAt, completedAt,
}); };
}
await this.store.updateTask(task.id, { workflowStepResults: results }); await this.store.updateTask(task.id, { workflowStepResults: results });
return { return {
allPassed: false, allPassed: false,
@@ -2947,15 +2959,16 @@ ${failureFeedback}
result.error || "Unknown error", result.error || "Unknown error",
); );
executorLog.error(`${task.id} — [pre-merge] workflow step failed: ${ws.name}; output captured in task log`); executorLog.error(`${task.id} — [pre-merge] workflow step failed: ${ws.name}; output captured in task log`);
results.push({ // Update existing pending entry in place
workflowStepId: ws.id, const existingIdx = results.findIndex(r => r.workflowStepId === ws.id);
workflowStepName: ws.name, if (existingIdx >= 0) {
phase: stepPhase, results[existingIdx] = {
status: "failed", ...results[existingIdx],
output: result.error || "Workflow step failed", status: "failed",
startedAt, output: result.error || "Workflow step failed",
completedAt, completedAt,
}); };
}
await this.store.updateTask(task.id, { workflowStepResults: results }); await this.store.updateTask(task.id, { workflowStepResults: results });
return { return {
allPassed: false, allPassed: false,
@@ -2972,15 +2985,16 @@ ${failureFeedback}
err.message || "Unknown error", err.message || "Unknown error",
); );
executorLog.error(`${task.id} — [pre-merge] workflow step error: ${ws.name}${err.message}`); executorLog.error(`${task.id} — [pre-merge] workflow step error: ${ws.name}${err.message}`);
results.push({ // Update existing pending entry in place
workflowStepId: ws.id, const existingIdx = results.findIndex(r => r.workflowStepId === ws.id);
workflowStepName: ws.name, if (existingIdx >= 0) {
phase: stepPhase, results[existingIdx] = {
status: "failed", ...results[existingIdx],
output: err.message || "Workflow step error", status: "failed",
startedAt, output: err.message || "Workflow step error",
completedAt, completedAt,
}); };
}
await this.store.updateTask(task.id, { workflowStepResults: results }); await this.store.updateTask(task.id, { workflowStepResults: results });
return { return {
allPassed: false, allPassed: false,