Files
fusion/packages/dashboard/app/components/PlannerInterventionTimeline.tsx
gsxdsm d10ea9aef1 FN-7519: add planner-overseer intervention timeline model and UI
Introduces a persisted planner-overseer intervention timeline surfaced in the task-detail Planner Oversight cluster, recording stage, reason, action taken, outcome, attempt count/limit, and source links for each intervention.

- Add core `PlannerInterventionEntry` type plus `recordPlannerIntervention`/`getPlannerInterventionTimeline` helpers that persist entries via the run-audit store under the `overseer:intervention` mutation
- Add `PlannerInterventionTimeline` dashboard component rendering the timeline (stage/reason/action/outcome/attempts/links) with associated styles
- Wire the new API route/legacy handler and TaskDetailModal integration to expose and render the timeline
- Add unit tests for the core helpers and the new UI component
- Add changeset for the new minor feature and update architecture/dashboard-guide docs

Files changed:
$(cat /tmp/diffstat_7519.txt)

Fusion-Task-Id: FN-7519

Fusion-Task-Lineage: 3c4fcda3-9eb2-46d3-b142-b0c7d6334cd0

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2026-07-04 21:28:17 -07:00

218 lines
8.9 KiB
TypeScript

import React, { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Loader2 } from "lucide-react";
import type { PlannerInterventionEntry, PlannerInterventionSourceLink } from "@fusion/core";
import { fetchPlannerInterventionTimeline } from "../api";
/*
FNXC:PlannerOversight 2026-07-04-18:00:
FN-7519 Intervention Timeline UI. Renders the FN-7519 planner-intervention
entries (stage / reason / action / outcome / attempt count+limit / source
links) inside/adjacent to the FN-7517 oversight cluster in TaskDetailModal.
Renders a calm empty state ("No planner interventions yet") rather than an
empty shell when there are no interventions, and safely falls back on
unknown/legacy/future stage/action/outcome enum values instead of throwing.
This component is a pure READ surface \u2014 FN-7520 owns wiring the actual
`recordPlannerIntervention` call-sites at overseer decision points.
*/
export interface PlannerInterventionTimelineProps {
taskId: string;
projectId?: string;
/** When true, the timeline renders nothing (no leftover shell) \u2014 used for the "oversight Off / undefined" branch so callers don't fetch or render an always-on empty container. */
hidden?: boolean;
}
const STAGE_LABEL_KEYS: Record<string, string> = {
executor: "taskDetail.oversight.interventions.stage.executor",
reviewer: "taskDetail.oversight.interventions.stage.reviewer",
merger: "taskDetail.oversight.interventions.stage.merger",
"pull-request": "taskDetail.oversight.interventions.stage.pullRequest",
"workflow-gate": "taskDetail.oversight.interventions.stage.workflowGate",
};
const ACTION_LABEL_KEYS: Record<string, string> = {
observe: "taskDetail.oversight.interventions.action.observe",
"inject-guidance": "taskDetail.oversight.interventions.action.injectGuidance",
retry: "taskDetail.oversight.interventions.action.retry",
"request-fix": "taskDetail.oversight.interventions.action.requestFix",
escalate: "taskDetail.oversight.interventions.action.escalate",
"request-confirmation": "taskDetail.oversight.interventions.action.requestConfirmation",
};
const OUTCOME_LABEL_KEYS: Record<string, string> = {
succeeded: "taskDetail.oversight.interventions.outcome.succeeded",
failed: "taskDetail.oversight.interventions.outcome.failed",
pending: "taskDetail.oversight.interventions.outcome.pending",
"awaiting-confirmation": "taskDetail.oversight.interventions.outcome.awaitingConfirmation",
skipped: "taskDetail.oversight.interventions.outcome.skipped",
};
const OUTCOME_DOT_MODIFIER: Record<string, string> = {
succeeded: "status-dot--online",
failed: "status-dot--error",
pending: "status-dot--pending",
"awaiting-confirmation": "status-dot--connecting",
skipped: "status-dot--skipped",
};
const SOURCE_LINK_LABEL_KEYS: Record<string, string> = {
"agent-log": "taskDetail.oversight.interventions.sourceLink.agentLog",
"review-comment": "taskDetail.oversight.interventions.sourceLink.reviewComment",
"failed-check": "taskDetail.oversight.interventions.sourceLink.failedCheck",
"merge-error": "taskDetail.oversight.interventions.sourceLink.mergeError",
"pr-state": "taskDetail.oversight.interventions.sourceLink.prState",
url: "taskDetail.oversight.interventions.sourceLink.url",
};
function SourceLinkChip({ link, index }: { link: PlannerInterventionSourceLink; index: number }) {
const { t } = useTranslation();
const kindLabel = t(SOURCE_LINK_LABEL_KEYS[link.kind] ?? SOURCE_LINK_LABEL_KEYS.url, link.kind);
const display = link.label && link.label.length > 0 ? link.label : kindLabel;
const href = link.url;
const content = (
<>
<span className="planner-intervention-source-link__kind">{kindLabel}</span>
<span className="planner-intervention-source-link__label">{display}</span>
</>
);
return href ? (
<a
key={`${link.kind}-${index}`}
className="planner-intervention-source-link"
data-testid="planner-intervention-source-link"
href={href}
target="_blank"
rel="noreferrer noopener"
title={link.target ?? href}
>
{content}
</a>
) : (
<span
key={`${link.kind}-${index}`}
className="planner-intervention-source-link planner-intervention-source-link--inert"
data-testid="planner-intervention-source-link"
title={link.target}
>
{content}
</span>
);
}
function InterventionEntryRow({ entry }: { entry: PlannerInterventionEntry }) {
const { t } = useTranslation();
const stageLabel = t(STAGE_LABEL_KEYS[entry.stage] ?? STAGE_LABEL_KEYS["workflow-gate"], entry.stage);
const actionLabel = t(ACTION_LABEL_KEYS[entry.action] ?? ACTION_LABEL_KEYS.observe, entry.action);
const outcomeLabel = t(OUTCOME_LABEL_KEYS[entry.outcome] ?? OUTCOME_LABEL_KEYS.pending, entry.outcome);
const dotModifier = OUTCOME_DOT_MODIFIER[entry.outcome] ?? "status-dot--pending";
const hasAttempts = typeof entry.attemptCount === "number" && typeof entry.attemptLimit === "number";
const timestampLabel = (() => {
try {
return new Date(entry.timestamp).toLocaleString();
} catch {
return entry.timestamp;
}
})();
return (
<li className="planner-intervention-entry" data-testid="planner-intervention-entry">
<div className="planner-intervention-entry__header">
<span className="planner-intervention-entry__stage" data-testid="planner-intervention-entry-stage">
{stageLabel}
</span>
<span className={`status-dot ${dotModifier}`} aria-hidden="true" />
<span className="planner-intervention-entry__outcome">{outcomeLabel}</span>
<span className="planner-intervention-entry__timestamp">{timestampLabel}</span>
</div>
<p className="planner-intervention-entry__reason">{entry.reason}</p>
<div className="planner-intervention-entry__meta">
<span className="planner-intervention-entry__action">{actionLabel}</span>
{hasAttempts && (
<span className="planner-intervention-entry__attempts" data-testid="planner-intervention-entry-attempts">
{t("taskDetail.oversight.interventions.attempts", "Attempt {{count}}/{{limit}}", {
count: entry.attemptCount,
limit: entry.attemptLimit,
})}
</span>
)}
</div>
{entry.sourceLinks && entry.sourceLinks.length > 0 && (
<div className="planner-intervention-entry__links" data-testid="planner-intervention-entry-links">
{entry.sourceLinks.map((link, index) => (
<SourceLinkChip key={`${link.kind}-${link.target ?? link.url ?? index}`} link={link} index={index} />
))}
</div>
)}
</li>
);
}
export function PlannerInterventionTimeline({ taskId, projectId, hidden }: PlannerInterventionTimelineProps) {
const { t } = useTranslation();
const [entries, setEntries] = useState<PlannerInterventionEntry[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [loadError, setLoadError] = useState(false);
useEffect(() => {
if (hidden) return;
let cancelled = false;
setIsLoading(true);
setLoadError(false);
fetchPlannerInterventionTimeline(taskId, projectId)
.then((result) => {
if (cancelled) return;
setEntries(Array.isArray(result?.entries) ? result.entries : []);
})
.catch(() => {
if (cancelled) return;
setEntries([]);
setLoadError(true);
})
.finally(() => {
if (cancelled) return;
setIsLoading(false);
});
return () => {
cancelled = true;
};
}, [taskId, projectId, hidden]);
// FNXC:PlannerOversight 2026-07-04-18:00: when hidden (oversight Off / oversight
// fields undefined), render nothing at all — no leftover empty container,
// scroll region, or dangling aria-label, per the Surface Enumeration gate.
if (hidden) return null;
return (
<section
className="task-oversight-timeline"
data-testid="planner-intervention-timeline"
aria-label={t("taskDetail.oversight.interventions.ariaLabel", "Planner intervention timeline")}
>
<h4 className="task-oversight-timeline__heading">
{t("taskDetail.oversight.interventions.heading", "Intervention timeline")}
</h4>
{isLoading ? (
<span className="task-oversight-timeline__loading">
<Loader2 className="spin" aria-hidden="true" />
{t("taskDetail.oversight.interventions.loading", "Loading intervention timeline\u2026")}
</span>
) : entries.length === 0 ? (
<p className="task-oversight-timeline__empty" data-testid="planner-intervention-timeline-empty">
{loadError
? t("taskDetail.oversight.interventions.loadError", "Unable to load intervention timeline")
: t("taskDetail.oversight.interventions.empty", "No planner interventions yet")}
</p>
) : (
<ul className="planner-intervention-list">
{entries.map((entry) => (
<InterventionEntryRow key={entry.id} entry={entry} />
))}
</ul>
)}
</section>
);
}