feat(dashboard): integrate stashed workflow-selection refactor; Dashboard rename; resolve CC conflicts
- Resolve the stash-pop conflicts in command-center CSS/test by keeping the current branch's newer FN-6784 responsive clamps, Team heartbeat docs, and recharts assertions (the stashed snapshot was older). - Land the stashed workflow-selection refactor (Board/NewTaskModal/WorkflowSwitcher; remove lastSelectedWorkflow util). - Rename Command Center -> Dashboard (i18n source) and move it to the top of the left sidebar; workflow switcher chevron sized to match the project selector. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -240,8 +240,6 @@ These values are sent with the Planning Mode create-task request as `branchSelec
|
||||
|
||||
When inline quick-create, Planning Mode, or Subtask Breakdown is opened from a workflow-filtered board/list lane, the create request also carries that active workflow selection. Quick-created tasks appear on the selected workflow lane immediately while board-workflows metadata refreshes, and planning saves, planning breakdown saves, and subtask-breakdown saves create their tasks directly on the selected workflow lane instead of briefly landing on the default board.
|
||||
|
||||
The **New Task** dialog's workflow selector also defaults to the current or last selected Board/List workflow lane for the current project. If no valid lane has been selected, or the remembered lane was deleted, the selector falls back to the project default workflow and task creation omits an explicit `workflowId`.
|
||||
|
||||
Completed single-task planning sessions remain in the Planning Mode history after you create the task, and selecting one restores the completed summary instead of restarting the composer. History rows are deduplicated by session id even if the initial load and live session updates arrive out of order, and deleting a history entry now waits for the server delete to persist (failures keep the row visible and surface an error instead of silently disappearing until refresh).
|
||||
|
||||
## New Task Modal Branch Strategy
|
||||
|
||||
@@ -16,7 +16,6 @@ import { getBoardCanDropTaskRejection } from "./boardCanDropTask";
|
||||
import { WorkflowSwitcher } from "./WorkflowSwitcher";
|
||||
import { computeWorkflowStatusCounts } from "./workflowStatusCounts";
|
||||
import { readBoardWorkflowsCache, writeBoardWorkflowsCache } from "../utils/boardWorkflowsCache";
|
||||
import { writeLastSelectedWorkflowId } from "../utils/lastSelectedWorkflow";
|
||||
|
||||
interface BoardProps {
|
||||
tasks: Task[];
|
||||
@@ -484,15 +483,6 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask
|
||||
}
|
||||
}, [selectedWorkflow, selectedWorkflowId, workflowMode]);
|
||||
|
||||
/**
|
||||
* FNXC:WorkflowDefaults 2026-06-22-00:00:
|
||||
* Persist explicit board-lane picks per project so the New Task dialog opens on the same current or last selected workflow lane without changing board filtering semantics.
|
||||
*/
|
||||
const handleSelectedWorkflowChange = useCallback((id: string) => {
|
||||
setSelectedWorkflowId(id);
|
||||
writeLastSelectedWorkflowId(projectId, id);
|
||||
}, [projectId]);
|
||||
|
||||
const selectedWorkflowTasks = useMemo(() => {
|
||||
if (!workflowMode || !boardWorkflows || !selectedWorkflow) return [];
|
||||
return tasks.filter((task) => {
|
||||
@@ -622,7 +612,7 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask
|
||||
<WorkflowSwitcher
|
||||
workflows={workflowOptions}
|
||||
value={selectedWorkflow.id}
|
||||
onChange={handleSelectedWorkflowChange}
|
||||
onChange={setSelectedWorkflowId}
|
||||
counts={workflowStatusCounts}
|
||||
onOpen={refreshBoardWorkflows}
|
||||
onEditWorkflow={onOpenWorkflowEditor}
|
||||
|
||||
@@ -278,6 +278,19 @@ export function LeftSidebarNav({
|
||||
Flag gates preserved verbatim from the prior layout: agents (showAgentsTab), goals (goalsView), insight (insights), research (researchView), skills (showSkillsTab), memory (memoryView), evals (evalsView). graph and compound are skipped when their plugin view is absent.
|
||||
*/
|
||||
const navEntries: SidebarNavEntry[] = [
|
||||
/*
|
||||
FNXC:Navigation 2026-06-22-01:15:
|
||||
Command Center is labeled "Dashboard" and sits at the very top of the sidebar. The board remains the default view on load (useViewState initial taskView is still "board").
|
||||
*/
|
||||
{
|
||||
id: "command-center",
|
||||
label: t("nav.commandCenter", "Dashboard"),
|
||||
view: "command-center",
|
||||
isActive: view === "command-center",
|
||||
icon: Gauge,
|
||||
testId: "sidebar-nav-command-center",
|
||||
onSelect: () => onChangeView("command-center"),
|
||||
},
|
||||
{
|
||||
id: "board",
|
||||
label: t("nav.board", "Board"),
|
||||
@@ -297,15 +310,6 @@ export function LeftSidebarNav({
|
||||
onSelect: () => onChangeView("list"),
|
||||
},
|
||||
...(graphPluginEntry ? [mapPluginEntry(graphPluginEntry)] : []),
|
||||
{
|
||||
id: "command-center",
|
||||
label: t("nav.commandCenter", "Command Center"),
|
||||
view: "command-center",
|
||||
isActive: view === "command-center",
|
||||
icon: Gauge,
|
||||
testId: "sidebar-nav-command-center",
|
||||
onSelect: () => onChangeView("command-center"),
|
||||
},
|
||||
...(showAgentsTab
|
||||
? [
|
||||
{
|
||||
|
||||
@@ -25,7 +25,6 @@ import { subscribeSse } from "../sse-bus";
|
||||
import { WorkflowSwitcher } from "./WorkflowSwitcher";
|
||||
import { computeWorkflowStatusCounts } from "./workflowStatusCounts";
|
||||
import { readBoardWorkflowsCache, writeBoardWorkflowsCache } from "../utils/boardWorkflowsCache";
|
||||
import { writeLastSelectedWorkflowId } from "../utils/lastSelectedWorkflow";
|
||||
|
||||
const COLUMN_COLOR_MAP: Record<Column, string> = {
|
||||
triage: "var(--triage)",
|
||||
@@ -630,15 +629,6 @@ export function ListView({
|
||||
}
|
||||
}, [selectedWorkflow, selectedWorkflowId, workflowMode]);
|
||||
|
||||
/**
|
||||
* FNXC:WorkflowDefaults 2026-06-22-00:00:
|
||||
* Persist explicit list-view workflow-lane picks per project so New Task inherits the user's current board context without changing list filtering or default resolution.
|
||||
*/
|
||||
const handleSelectedWorkflowChange = useCallback((id: string) => {
|
||||
setSelectedWorkflowId(id);
|
||||
writeLastSelectedWorkflowId(projectId, id);
|
||||
}, [projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedColumn(null);
|
||||
}, [selectedWorkflowId]);
|
||||
@@ -1672,7 +1662,7 @@ export function ListView({
|
||||
<WorkflowSwitcher
|
||||
workflows={workflowOptions}
|
||||
value={selectedWorkflow.id}
|
||||
onChange={handleSelectedWorkflowChange}
|
||||
onChange={setSelectedWorkflowId}
|
||||
counts={workflowStatusCounts}
|
||||
onOpen={refreshBoardWorkflows}
|
||||
label={t("listView.workflowLabel", "Workflow")}
|
||||
|
||||
@@ -17,8 +17,6 @@ import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
|
||||
import { useNodes } from "../hooks/useNodes";
|
||||
import { useViewportMode } from "../hooks/useViewportMode";
|
||||
import { useAgentsMapCache } from "../hooks/useAgentsMapCache";
|
||||
import { readBoardWorkflowsCache } from "../utils/boardWorkflowsCache";
|
||||
import { readLastSelectedWorkflowId } from "../utils/lastSelectedWorkflow";
|
||||
|
||||
interface NewTaskModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -66,7 +64,6 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
|
||||
// `null` = explicit "No workflow", `string` = a specific workflow. Materialized
|
||||
// atomically at create time via the `workflowId` create parameter.
|
||||
const [selectedWorkflowId, setSelectedWorkflowId] = useState<string | null | undefined>(undefined);
|
||||
const initialWorkflowIdRef = useRef<string | null | undefined>(undefined);
|
||||
// Optional workflow steps the user opted into; TaskForm fetches + seeds these
|
||||
// from the selected workflow's defaultOn and lifts the enabled set up here.
|
||||
const [enabledWorkflowSteps, setEnabledWorkflowSteps] = useState<string[]>([]);
|
||||
@@ -99,23 +96,13 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
|
||||
/**
|
||||
* FNXC:SelectionComment 2026-06-16-23:58:
|
||||
* Selection comments open the normal New Task dialog with a prefilled description; seed only on the closed→open transition so rerenders do not overwrite user edits.
|
||||
*
|
||||
* FNXC:WorkflowDefaults 2026-06-22-00:00:
|
||||
* Seed New Task's workflow selector from the project's last selected board lane only when that lane still exists in the board-workflows cache. Store the seed as the pristine baseline so opening on a remembered lane does not trigger discard-changes confirmation, and reset returns to that same baseline.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (isOpen && !wasOpenRef.current) {
|
||||
setDescription(initialDescription);
|
||||
const persistedWorkflowId = readLastSelectedWorkflowId(projectId);
|
||||
const cachedWorkflows = readBoardWorkflowsCache(projectId);
|
||||
const initialWorkflowId = persistedWorkflowId && cachedWorkflows?.workflows.some((workflow) => workflow.id === persistedWorkflowId)
|
||||
? persistedWorkflowId
|
||||
: undefined;
|
||||
initialWorkflowIdRef.current = initialWorkflowId;
|
||||
setSelectedWorkflowId(initialWorkflowId);
|
||||
}
|
||||
wasOpenRef.current = isOpen;
|
||||
}, [initialDescription, isOpen, projectId]);
|
||||
}, [initialDescription, isOpen]);
|
||||
|
||||
// Load agents for agent picker
|
||||
const loadAgents = useCallback(() => {
|
||||
@@ -184,7 +171,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
|
||||
description.trim() !== "" ||
|
||||
dependencies.length > 0 ||
|
||||
pendingImages.length > 0 ||
|
||||
selectedWorkflowId !== initialWorkflowIdRef.current ||
|
||||
selectedWorkflowId !== undefined ||
|
||||
// Optional workflow steps the user toggled count as unsaved work. (Workflows
|
||||
// whose steps are defaultOn:false — today's only shipped step — seed an empty
|
||||
// set, so this stays false until the user actually opts a step in.)
|
||||
@@ -220,7 +207,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
|
||||
setThinkingLevel("");
|
||||
setSelectedPresetId("");
|
||||
setPresetMode("default");
|
||||
setSelectedWorkflowId(initialWorkflowIdRef.current);
|
||||
setSelectedWorkflowId(undefined);
|
||||
setEnabledWorkflowSteps([]);
|
||||
setSelectedAgentId(null);
|
||||
setShowAgentPicker(false);
|
||||
@@ -348,7 +335,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
|
||||
setThinkingLevel("");
|
||||
setSelectedPresetId("");
|
||||
setPresetMode("default");
|
||||
setSelectedWorkflowId(initialWorkflowIdRef.current);
|
||||
setSelectedWorkflowId(undefined);
|
||||
setEnabledWorkflowSteps([]);
|
||||
setSelectedAgentId(null);
|
||||
setShowAgentPicker(false);
|
||||
|
||||
@@ -387,7 +387,7 @@ export function WorkflowSwitcher({ workflows, value, onChange, counts, onOpen, l
|
||||
{isOpen ? renderCountBadges(selectedCounts, "trigger") : null}
|
||||
{isOpen ? renderAccessibleCounts(selectedCounts) : null}
|
||||
</span>
|
||||
<ChevronDown className="workflow-switcher-chevron" aria-hidden="true" />
|
||||
<ChevronDown size={14} className="workflow-switcher-chevron" aria-hidden="true" />
|
||||
</button>
|
||||
{dropdown}
|
||||
</div>
|
||||
|
||||
@@ -267,9 +267,9 @@ describe("LeftSidebarNav", () => {
|
||||
*/
|
||||
const primaryButtons = within(primaryNav).getAllByRole("button");
|
||||
const orderedTestIds = [
|
||||
"sidebar-nav-command-center",
|
||||
"sidebar-nav-board",
|
||||
"sidebar-nav-list",
|
||||
"sidebar-nav-command-center",
|
||||
"sidebar-nav-agents",
|
||||
"sidebar-nav-chat",
|
||||
"sidebar-nav-mailbox",
|
||||
|
||||
@@ -3,9 +3,6 @@ import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import type { ComponentProps } from "react";
|
||||
import { NewTaskModal } from "../NewTaskModal";
|
||||
import type { Task, Column } from "@fusion/core";
|
||||
import type { BoardWorkflowsPayload } from "../../api";
|
||||
import { writeBoardWorkflowsCache } from "../../utils/boardWorkflowsCache";
|
||||
import { writeLastSelectedWorkflowId } from "../../utils/lastSelectedWorkflow";
|
||||
|
||||
// Mock lucide-react
|
||||
vi.mock("lucide-react", () => ({
|
||||
@@ -79,43 +76,6 @@ function makeTask(id: string): Task {
|
||||
};
|
||||
}
|
||||
|
||||
function workflowDefinition(id: string, name: string) {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
description: "",
|
||||
kind: "workflow" as const,
|
||||
ir: { version: "v1", name, nodes: [], edges: [] },
|
||||
layout: {},
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
};
|
||||
}
|
||||
|
||||
function boardWorkflowsPayload(workflows: Array<{ id: string; name: string }>, defaultWorkflowId = workflows[0]?.id ?? "builtin:coding"): BoardWorkflowsPayload {
|
||||
return {
|
||||
flagEnabled: true,
|
||||
defaultWorkflowId,
|
||||
workflows: workflows.map((workflow) => ({
|
||||
id: workflow.id,
|
||||
name: workflow.name,
|
||||
columns: [{ id: "todo", name: "Todo", flags: {} }],
|
||||
})),
|
||||
taskWorkflowIds: {},
|
||||
};
|
||||
}
|
||||
|
||||
async function mockSelectableWorkflows(workflows: Array<{ id: string; name: string }>, defaultWorkflowId = workflows[0]?.id) {
|
||||
const { fetchSettings, fetchWorkflows } = await import("../../api");
|
||||
vi.mocked(fetchSettings).mockResolvedValueOnce({
|
||||
modelPresets: [],
|
||||
autoSelectModelPreset: false,
|
||||
defaultPresetBySize: {},
|
||||
defaultWorkflowId,
|
||||
});
|
||||
vi.mocked(fetchWorkflows).mockResolvedValueOnce(workflows.map((workflow) => workflowDefinition(workflow.id, workflow.name)) as any);
|
||||
}
|
||||
|
||||
function renderNewTaskModal(props: Partial<ComponentProps<typeof NewTaskModal>> = {}) {
|
||||
const defaultProps: ComponentProps<typeof NewTaskModal> = {
|
||||
isOpen: true,
|
||||
@@ -132,8 +92,6 @@ function renderNewTaskModal(props: Partial<ComponentProps<typeof NewTaskModal>>
|
||||
describe("NewTaskModal", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
window.localStorage.clear();
|
||||
window.sessionStorage.clear();
|
||||
mockConfirm.mockReset();
|
||||
mockConfirm.mockResolvedValue(true);
|
||||
mockUseMobileKeyboard.mockReturnValue({
|
||||
@@ -192,11 +150,7 @@ describe("NewTaskModal", () => {
|
||||
expect(screen.getByRole("button", { name: "Cancel" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("exposes New Task dialog quick-add affordance parity when AI handoff callbacks are supplied", async () => {
|
||||
await mockSelectableWorkflows([
|
||||
{ id: "WF-DEFAULT", name: "Default workflow" },
|
||||
{ id: "WF-LANE", name: "Selected lane" },
|
||||
], "WF-DEFAULT");
|
||||
it("exposes New Task dialog quick-add affordance parity when AI handoff callbacks are supplied", () => {
|
||||
renderNewTaskModal({
|
||||
onPlanningMode: vi.fn(),
|
||||
onSubtaskBreakdown: vi.fn(),
|
||||
@@ -204,7 +158,7 @@ describe("NewTaskModal", () => {
|
||||
|
||||
fireEvent.change(screen.getByRole("textbox"), { target: { value: "Create parity coverage" } });
|
||||
|
||||
// Canonical QuickEntryBox action row includes Save/Create, Fast, GitHub, Priority, Plan, Subtask, Refine, Deps, Attach, Models, Node, Agent, and the inherited workflow lane; the modal maps these to existing TaskForm/quick-field controls instead of duplicating implementations.
|
||||
// Canonical QuickEntryBox action row includes Plan, Subtask, Refine, Deps, Attach, Models, Node, and Agent affordances; the modal maps these to existing TaskForm/quick-field controls instead of duplicating implementations.
|
||||
expect(screen.getAllByTestId("task-form-plan-button")).toHaveLength(1);
|
||||
expect(screen.getAllByTestId("task-form-subtask-button")).toHaveLength(1);
|
||||
expect(screen.getByTestId("refine-button")).toBeInTheDocument();
|
||||
@@ -217,12 +171,7 @@ describe("NewTaskModal", () => {
|
||||
expect(screen.getByTestId("task-form-github-tracking")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("task-priority-select")).toBeInTheDocument();
|
||||
expect(screen.getByText(/Attachments/i)).toBeInTheDocument();
|
||||
expect(await screen.findByText(/Executor/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Reviewer/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Planning/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Node Override/i)).toBeInTheDocument();
|
||||
expect(await screen.findByTestId("task-workflow-select")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Create Task" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the Fast and standard execution-mode affordance inside More options", () => {
|
||||
@@ -900,93 +849,6 @@ describe("NewTaskModal", () => {
|
||||
});
|
||||
}
|
||||
|
||||
it("defaults to the persisted valid board lane and submits that workflowId", async () => {
|
||||
const workflows = [
|
||||
{ id: "WF-DEFAULT", name: "Default workflow" },
|
||||
{ id: "WF-LANE", name: "Selected lane" },
|
||||
];
|
||||
writeLastSelectedWorkflowId("project-a", "WF-LANE");
|
||||
writeBoardWorkflowsCache("project-a", boardWorkflowsPayload(workflows, "WF-DEFAULT"));
|
||||
await mockSelectableWorkflows(workflows, "WF-DEFAULT");
|
||||
const { props } = renderNewTaskModal({ projectId: "project-a" });
|
||||
|
||||
const select = await screen.findByTestId("task-workflow-select") as HTMLSelectElement;
|
||||
await waitFor(() => expect(select).toHaveValue("WF-LANE"));
|
||||
|
||||
fireEvent.change(screen.getByRole("textbox"), { target: { value: "Use selected lane" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(props.onCreateTask).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ workflowId: "WF-LANE" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("inherits the project default and omits workflowId when no lane is persisted", async () => {
|
||||
const workflows = [
|
||||
{ id: "WF-DEFAULT", name: "Default workflow" },
|
||||
{ id: "WF-LANE", name: "Selected lane" },
|
||||
];
|
||||
await mockSelectableWorkflows(workflows, "WF-DEFAULT");
|
||||
const { props } = renderNewTaskModal({ projectId: "project-a" });
|
||||
|
||||
const select = await screen.findByTestId("task-workflow-select") as HTMLSelectElement;
|
||||
await waitFor(() => expect(select).toHaveValue("WF-DEFAULT"));
|
||||
|
||||
fireEvent.change(screen.getByRole("textbox"), { target: { value: "Inherit default lane" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(props.onCreateTask).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
const payload = vi.mocked(props.onCreateTask).mock.calls[0][0] as Record<string, unknown>;
|
||||
expect(payload).not.toHaveProperty("workflowId");
|
||||
});
|
||||
|
||||
it("ignores a stale persisted lane and falls back to the project default", async () => {
|
||||
const workflows = [
|
||||
{ id: "WF-DEFAULT", name: "Default workflow" },
|
||||
{ id: "WF-LANE", name: "Selected lane" },
|
||||
];
|
||||
writeLastSelectedWorkflowId("project-a", "WF-DELETED");
|
||||
writeBoardWorkflowsCache("project-a", boardWorkflowsPayload(workflows, "WF-DEFAULT"));
|
||||
await mockSelectableWorkflows(workflows, "WF-DEFAULT");
|
||||
const { props } = renderNewTaskModal({ projectId: "project-a" });
|
||||
|
||||
const select = await screen.findByTestId("task-workflow-select") as HTMLSelectElement;
|
||||
await waitFor(() => expect(select).toHaveValue("WF-DEFAULT"));
|
||||
|
||||
fireEvent.change(screen.getByRole("textbox"), { target: { value: "Ignore deleted lane" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(props.onCreateTask).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
const payload = vi.mocked(props.onCreateTask).mock.calls[0][0] as Record<string, unknown>;
|
||||
expect(payload).not.toHaveProperty("workflowId");
|
||||
});
|
||||
|
||||
it("does not treat a seeded persisted lane as dirty on cancel", async () => {
|
||||
const workflows = [
|
||||
{ id: "WF-DEFAULT", name: "Default workflow" },
|
||||
{ id: "WF-LANE", name: "Selected lane" },
|
||||
];
|
||||
writeLastSelectedWorkflowId("project-a", "WF-LANE");
|
||||
writeBoardWorkflowsCache("project-a", boardWorkflowsPayload(workflows, "WF-DEFAULT"));
|
||||
await mockSelectableWorkflows(workflows, "WF-DEFAULT");
|
||||
const { props } = renderNewTaskModal({ projectId: "project-a" });
|
||||
|
||||
const select = await screen.findByTestId("task-workflow-select") as HTMLSelectElement;
|
||||
await waitFor(() => expect(select).toHaveValue("WF-LANE"));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(props.onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(mockConfirm).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("omits workflowId from the payload when the picker is untouched (inherit default)", async () => {
|
||||
await mockWorkflows([{ id: "WF-1", name: "QA" }]);
|
||||
const { props } = renderNewTaskModal();
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { readLastSelectedWorkflowId, writeLastSelectedWorkflowId } from "../lastSelectedWorkflow";
|
||||
|
||||
describe("lastSelectedWorkflow", () => {
|
||||
afterEach(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
window.localStorage.clear();
|
||||
}
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("round-trips the selected workflow id per project", () => {
|
||||
writeLastSelectedWorkflowId("project-a", "WF-1");
|
||||
|
||||
expect(readLastSelectedWorkflowId("project-a")).toBe("WF-1");
|
||||
});
|
||||
|
||||
it("keeps project cache keys isolated", () => {
|
||||
writeLastSelectedWorkflowId("project-a", "WF-1");
|
||||
writeLastSelectedWorkflowId("project-b", "WF-2");
|
||||
|
||||
expect(readLastSelectedWorkflowId("project-a")).toBe("WF-1");
|
||||
expect(readLastSelectedWorkflowId("project-b")).toBe("WF-2");
|
||||
});
|
||||
|
||||
it("uses a default key for callers without a project id", () => {
|
||||
writeLastSelectedWorkflowId(undefined, "WF-default");
|
||||
writeLastSelectedWorkflowId("project-a", "WF-project");
|
||||
|
||||
expect(readLastSelectedWorkflowId()).toBe("WF-default");
|
||||
expect(readLastSelectedWorkflowId("project-a")).toBe("WF-project");
|
||||
});
|
||||
|
||||
it("returns null for missing, empty, or non-string entries", () => {
|
||||
expect(readLastSelectedWorkflowId("missing")).toBeNull();
|
||||
|
||||
window.localStorage.setItem("fusion:last-selected-workflow:empty", "");
|
||||
expect(readLastSelectedWorkflowId("empty")).toBeNull();
|
||||
|
||||
vi.spyOn(Storage.prototype, "getItem").mockReturnValueOnce({ workflowId: "WF-1" } as unknown as string);
|
||||
expect(readLastSelectedWorkflowId("non-string")).toBeNull();
|
||||
});
|
||||
|
||||
it("swallows localStorage write failures", () => {
|
||||
vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
|
||||
throw new Error("quota");
|
||||
});
|
||||
|
||||
expect(() => writeLastSelectedWorkflowId("project-a", "WF-1")).not.toThrow();
|
||||
});
|
||||
|
||||
it("swallows localStorage read failures", () => {
|
||||
vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => {
|
||||
throw new Error("private mode");
|
||||
});
|
||||
|
||||
expect(readLastSelectedWorkflowId("project-a")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null without window for SSR callers", () => {
|
||||
vi.stubGlobal("window", undefined);
|
||||
|
||||
expect(readLastSelectedWorkflowId("project-a")).toBeNull();
|
||||
expect(() => writeLastSelectedWorkflowId("project-a", "WF-1")).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -1,31 +0,0 @@
|
||||
const LAST_SELECTED_WORKFLOW_PREFIX = "fusion:last-selected-workflow:";
|
||||
const DEFAULT_PROJECT_CACHE_KEY = "default";
|
||||
|
||||
function cacheKey(projectId?: string): string {
|
||||
return `${LAST_SELECTED_WORKFLOW_PREFIX}${projectId ?? DEFAULT_PROJECT_CACHE_KEY}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* FNXC:WorkflowDefaults 2026-06-22-00:00:
|
||||
* Persist the board's current workflow lane per project so New Task can default its existing workflow selector to the current or last selected lane without leaking choices across projects.
|
||||
*/
|
||||
export function readLastSelectedWorkflowId(projectId?: string): string | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
|
||||
try {
|
||||
const value = window.localStorage.getItem(cacheKey(projectId));
|
||||
return typeof value === "string" && value.trim() !== "" ? value : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeLastSelectedWorkflowId(projectId: string | undefined, workflowId: string): void {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
try {
|
||||
window.localStorage.setItem(cacheKey(projectId), workflowId);
|
||||
} catch {
|
||||
// Private-mode/quota failures should never block board workflow selection.
|
||||
}
|
||||
}
|
||||
@@ -1627,7 +1627,7 @@
|
||||
"repoValue": "{{filed}} filed / {{fixed}} fixed",
|
||||
"totalsTitle": "GitHub issue flow"
|
||||
},
|
||||
"heading": "Command Center",
|
||||
"heading": "Dashboard",
|
||||
"loading": "Loading command center...",
|
||||
"missionControl": {
|
||||
"activeNodes": "Active nodes",
|
||||
@@ -2858,7 +2858,7 @@
|
||||
"browseFiles": "Browse Files",
|
||||
"chatView": "Chat view",
|
||||
"closeSearch": "Close search",
|
||||
"commandCenterView": "Command Center",
|
||||
"commandCenterView": "Dashboard",
|
||||
"createTaskWithPlanning": "Create a task with AI planning",
|
||||
"devServerView": "Dev Server",
|
||||
"documentsView": "Artifacts view",
|
||||
@@ -3936,7 +3936,7 @@
|
||||
"chat": "Chat",
|
||||
"chatUnreadAriaLabel": "Unread chat response",
|
||||
"collapseSidebar": "Collapse sidebar",
|
||||
"commandCenter": "Command Center",
|
||||
"commandCenter": "Dashboard",
|
||||
"devServer": "Dev Server",
|
||||
"documents": "Artifacts",
|
||||
"evals": "Evals",
|
||||
|
||||
Reference in New Issue
Block a user