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

This commit is contained in:
gsxdsm
2026-04-28 09:03:02 -07:00
parent 8160f6fcf6
commit 7d66ffa9cc
4 changed files with 98 additions and 55 deletions

View File

@@ -0,0 +1,9 @@
.routing-tab {
width: 100%;
}
@media (max-width: 768px) {
.routing-tab {
min-width: 0;
}
}

View File

@@ -0,0 +1,45 @@
import "./RoutingTab.css";
import type { Settings, Task, TaskDetail } from "@fusion/core";
interface RoutingTabProps {
task: Task | TaskDetail;
settings?: Settings;
projectId?: string;
}
export function RoutingTab({ task, settings, projectId }: RoutingTabProps) {
void projectId;
return (
<div className="detail-section routing-tab">
<h4>Node Routing</h4>
<dl className="detail-source-grid">
<div>
<dt>Task Override</dt>
<dd>{task.nodeId ?? <span className="detail-source-empty">(none)</span>}</dd>
</div>
<div>
<dt>Effective Node</dt>
<dd>{(task as Task & { effectiveNodeId?: string }).effectiveNodeId ?? "local execution"}</dd>
</div>
<div>
<dt>Routing Source</dt>
<dd>{(task as Task & { effectiveNodeSource?: string }).effectiveNodeSource ?? "local"}</dd>
</div>
<div>
<dt>Unavailable Node Policy</dt>
<dd>{(settings as (Settings & { unavailableNodePolicy?: string }) | undefined)?.unavailableNodePolicy ?? "block"}</dd>
</div>
<div>
<dt>Blocking Reason</dt>
<dd>
{((task as Task & { blockedReason?: string; statusReason?: string }).blockedReason ||
(task as Task & { statusReason?: string }).statusReason) ?? (
<span className="detail-source-empty">(not blocked)</span>
)}
</dd>
</div>
</dl>
</div>
);
}

View File

@@ -21,6 +21,7 @@ import { TaskChangesTab } from "./TaskChangesTab";
import { TaskForm, type PendingImage } from "./TaskForm";
import { useNodes } from "../hooks/useNodes";
import { WorkflowResultsTab } from "./WorkflowResultsTab";
import { RoutingTab } from "./RoutingTab";
import { TaskDocumentsTab } from "./TaskDocumentsTab";
import { TaskTokenStatsPanel } from "./TaskTokenStatsPanel";
import { PluginSlot } from "./PluginSlot";
@@ -164,7 +165,7 @@ function formatBytes(bytes: number): string {
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
type TabId = "definition" | "logs" | "changes" | "comments" | "model" | "workflow" | "documents" | "stats" | `plugin-${string}`;
type TabId = "definition" | "logs" | "changes" | "comments" | "model" | "routing" | "workflow" | "documents" | "stats" | `plugin-${string}`;
interface TaskDetailModalProps {
task: Task | TaskDetail;
@@ -1481,6 +1482,12 @@ export function TaskDetailModal({
>
Model
</button>
<button
className={`detail-tab${activeTab === "routing" ? " detail-tab-active" : ""}`}
onClick={() => setActiveTab("routing")}
>
Routing
</button>
<button
className={`detail-tab${activeTab === "workflow" ? " detail-tab-active" : ""}`}
onClick={() => setActiveTab("workflow")}
@@ -1524,6 +1531,8 @@ export function TaskDetailModal({
<div className="detail-section">
<ModelSelectorTab task={task} addToast={addToast} onTaskUpdated={onTaskUpdated} settings={settings} />
</div>
) : activeTab === "routing" ? (
<RoutingTab task={task} settings={settings} projectId={projectId} />
) : activeTab === "logs" ? (
<div className={`detail-section${logSubview === "agent-log" ? " detail-section--agent-log" : ""}`}>
<div className="log-subview-toggle">
@@ -1620,31 +1629,6 @@ export function TaskDetailModal({
</div>
)}
<MergeDetails task={task} />
<div className="detail-section">
<h4>Node Routing</h4>
<dl className="detail-source-grid">
<div>
<dt>Task Override</dt>
<dd>{task.nodeId ?? <span className="detail-source-empty">(none)</span>}</dd>
</div>
<div>
<dt>Effective Node</dt>
<dd>{(task as Task & { effectiveNodeId?: string }).effectiveNodeId ?? "local execution"}</dd>
</div>
<div>
<dt>Routing Source</dt>
<dd>{(task as Task & { effectiveNodeSource?: string }).effectiveNodeSource ?? "local"}</dd>
</div>
<div>
<dt>Unavailable Node Policy</dt>
<dd>{(settings as Settings & { unavailableNodePolicy?: string } | undefined)?.unavailableNodePolicy ?? "block"}</dd>
</div>
<div>
<dt>Blocking Reason</dt>
<dd>{((task as Task & { blockedReason?: string; statusReason?: string }).blockedReason || (task as Task & { statusReason?: string }).statusReason) ?? <span className="detail-source-empty">(not blocked)</span>}</dd>
</div>
</dl>
</div>
{task.sourceIssue && (
<div className="detail-section detail-source-section">
<h4>Source Issue</h4>

View File

@@ -1660,23 +1660,24 @@ describe("TaskDetailModal", () => {
// For an in-progress task (no workflow steps, no merge commit), the
// top-level tabs are: Definition, Logs, Changes, Comments, Documents,
// Model, Workflow, Stats.
const tabTexts = ["Definition", "Logs", "Changes", "Comments", "Documents", "Model", "Workflow", "Stats"];
// Model, Routing, Workflow, Stats.
const tabTexts = ["Definition", "Logs", "Changes", "Comments", "Documents", "Model", "Routing", "Workflow", "Stats"];
const tabs = screen.getAllByRole("button").filter((b) =>
tabTexts.includes(b.textContent || "")
);
expect(tabs.length).toBe(8);
expect(tabs.length).toBe(9);
expect(tabs[0].textContent).toBe("Definition");
expect(tabs[1].textContent).toBe("Logs");
expect(tabs[2].textContent).toBe("Changes");
expect(tabs[3].textContent).toBe("Comments");
expect(tabs[4].textContent).toBe("Documents");
expect(tabs[5].textContent).toBe("Model");
expect(tabs[6].textContent).toBe("Workflow");
expect(tabs[7].textContent).toBe("Stats");
expect(tabs[6].textContent).toBe("Routing");
expect(tabs[7].textContent).toBe("Workflow");
expect(tabs[8].textContent).toBe("Stats");
// Activity and Agent Log are NOT top-level tabs (they are subviews inside Logs)
expect(container.querySelectorAll(".detail-tab").length).toBe(8);
expect(container.querySelectorAll(".detail-tab").length).toBe(9);
// Workflow tab should always appear even when no workflow steps are configured
expect(screen.getByText("Workflow")).toBeInTheDocument();
// Commits tab should NOT appear for non-done tasks
@@ -2468,7 +2469,7 @@ describe("TaskDetailModal", () => {
expect(container.querySelector(".modal-actions .modal-actions-spacer")).toBeTruthy();
expect(container.querySelector(".detail-body")).toBeTruthy();
const tabs = container.querySelectorAll(".detail-tab");
expect(tabs.length).toBe(8);
expect(tabs.length).toBe(9);
expect(tabs[0].classList.contains("detail-tab-active")).toBe(true);
expect(Array.from(tabs).slice(1).every((t) => !t.classList.contains("detail-tab-active"))).toBe(true);
// Responsive CSS controls sizing — no inline padding/fontSize/borderBottom leaks
@@ -3166,18 +3167,19 @@ describe("TaskDetailModal", () => {
/>,
);
// In-progress tasks show exactly 8 tabs:
// Definition, Logs, Changes, Comments, Documents, Model, Workflow, Stats
// In-progress tasks show exactly 9 tabs:
// Definition, Logs, Changes, Comments, Documents, Model, Routing, Workflow, Stats
const tabs = container.querySelectorAll(".detail-tab");
expect(tabs.length).toBe(8);
expect(tabs.length).toBe(9);
expect(tabs[0].textContent).toBe("Definition");
expect(tabs[1].textContent).toBe("Logs");
expect(tabs[2].textContent).toBe("Changes");
expect(tabs[3].textContent).toBe("Comments");
expect(tabs[4].textContent).toBe("Documents");
expect(tabs[5].textContent).toBe("Model");
expect(tabs[6].textContent).toBe("Workflow");
expect(tabs[7].textContent).toBe("Stats");
expect(tabs[6].textContent).toBe("Routing");
expect(tabs[7].textContent).toBe("Workflow");
expect(tabs[8].textContent).toBe("Stats");
// Commits tab should NOT be present for non-done tasks
expect(screen.queryByText("Commits")).toBeNull();
});
@@ -3195,17 +3197,18 @@ describe("TaskDetailModal", () => {
/>,
);
// In-progress task with workflow steps: 8 tabs (Workflow after Model, Stats last)
// In-progress task with workflow steps: 9 tabs (Routing between Model and Workflow, Stats last)
const tabs = container.querySelectorAll(".detail-tab");
expect(tabs.length).toBe(8);
expect(tabs.length).toBe(9);
expect(tabs[0].textContent).toBe("Definition");
expect(tabs[1].textContent).toBe("Logs");
expect(tabs[2].textContent).toBe("Changes");
expect(tabs[3].textContent).toBe("Comments");
expect(tabs[4].textContent).toBe("Documents");
expect(tabs[5].textContent).toBe("Model");
expect(tabs[6].textContent).toBe("Workflow");
expect(tabs[7].textContent).toBe("Stats");
expect(tabs[6].textContent).toBe("Routing");
expect(tabs[7].textContent).toBe("Workflow");
expect(tabs[8].textContent).toBe("Stats");
});
it("does NOT show Commits tab for done task with mergeDetails.commitSha (changes merged into Changes tab)", () => {
@@ -3224,22 +3227,23 @@ describe("TaskDetailModal", () => {
/>,
);
// Done task with commit SHA: Definition, Logs, Changes, Comments, Documents, Model, Workflow, Stats (8 tabs, no Commits)
// Done task with commit SHA: Definition, Logs, Changes, Comments, Documents, Model, Routing, Workflow, Stats (9 tabs, no Commits)
const tabs = container.querySelectorAll(".detail-tab");
expect(tabs.length).toBe(8);
expect(tabs.length).toBe(9);
expect(tabs[0].textContent).toBe("Definition");
expect(tabs[1].textContent).toBe("Logs");
expect(tabs[2].textContent).toBe("Changes");
expect(tabs[3].textContent).toBe("Comments");
expect(tabs[4].textContent).toBe("Documents");
expect(tabs[5].textContent).toBe("Model");
expect(tabs[6].textContent).toBe("Workflow");
expect(tabs[7].textContent).toBe("Stats");
expect(tabs[6].textContent).toBe("Routing");
expect(tabs[7].textContent).toBe("Workflow");
expect(tabs[8].textContent).toBe("Stats");
// Commits tab should NOT be present
expect(screen.queryByText("Commits")).toBeNull();
});
it("shows 8 tabs for done task with workflow steps and commit SHA (Commits merged into Changes)", () => {
it("shows 9 tabs for done task with workflow steps and commit SHA (Commits merged into Changes)", () => {
const { container } = render(
<TaskDetailModal
task={makeTask({
@@ -3256,17 +3260,18 @@ describe("TaskDetailModal", () => {
/>,
);
// Done task with workflow steps and commit SHA: 8 tabs (no Commits)
// Done task with workflow steps and commit SHA: 9 tabs (no Commits)
const tabs = container.querySelectorAll(".detail-tab");
expect(tabs.length).toBe(8);
expect(tabs.length).toBe(9);
expect(tabs[0].textContent).toBe("Definition");
expect(tabs[1].textContent).toBe("Logs");
expect(tabs[2].textContent).toBe("Changes");
expect(tabs[3].textContent).toBe("Comments");
expect(tabs[4].textContent).toBe("Documents");
expect(tabs[5].textContent).toBe("Model");
expect(tabs[6].textContent).toBe("Workflow");
expect(tabs[7].textContent).toBe("Stats");
expect(tabs[6].textContent).toBe("Routing");
expect(tabs[7].textContent).toBe("Workflow");
expect(tabs[8].textContent).toBe("Stats");
// Commits tab should NOT be present
expect(screen.queryByText("Commits")).toBeNull();
});
@@ -3285,9 +3290,9 @@ describe("TaskDetailModal", () => {
);
const triageTabs = triageContainer.querySelectorAll(".detail-tab");
expect(triageTabs.length).toBe(7); // Definition, Logs, Comments, Documents, Model, Workflow, Stats
expect(triageTabs.length).toBe(8); // Definition, Logs, Comments, Documents, Model, Routing, Workflow, Stats
expect(Array.from(triageTabs).map(t => t.textContent)).toEqual([
"Definition", "Logs", "Comments", "Documents", "Model", "Workflow", "Stats",
"Definition", "Logs", "Comments", "Documents", "Model", "Routing", "Workflow", "Stats",
]);
const { container: todoContainer } = render(
@@ -3303,9 +3308,9 @@ describe("TaskDetailModal", () => {
);
const todoTabs = todoContainer.querySelectorAll(".detail-tab");
expect(todoTabs.length).toBe(7); // Definition, Logs, Comments, Documents, Model, Workflow, Stats
expect(todoTabs.length).toBe(8); // Definition, Logs, Comments, Documents, Model, Routing, Workflow, Stats
expect(Array.from(todoTabs).map(t => t.textContent)).toEqual([
"Definition", "Logs", "Comments", "Documents", "Model", "Workflow", "Stats",
"Definition", "Logs", "Comments", "Documents", "Model", "Routing", "Workflow", "Stats",
]);
});