feat(FN-3080): document graph navigation canonicalization in plugin authori

Adds documentation for graph navigation canonicalization (FN-3080) covering plugin authoring and dashboard usage, along with a changeset for the `@runfusion/fusion` package release.

Fusion-Task-Id: FN-3080
This commit is contained in:
Fusion
2026-05-06 22:53:50 -07:00
committed by gsxdsm
parent be3b4326d9
commit 1a0124c5da
12 changed files with 106 additions and 44 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Normalize dependency graph dashboard navigation so Graph resolves through a canonical `graph` task view destination and appears only in secondary navigation surfaces (desktop Header overflow and mobile More sheet). Also add TaskCard embedding support via `disableDrag` for plugin-hosted graph nodes.

View File

@@ -612,6 +612,11 @@ Placement guidance:
- `overflow`: desktop header overflow menu - `overflow`: desktop header overflow menu
- `more`: mobile More sheet / secondary nav surfaces - `more`: mobile More sheet / secondary nav surfaces
Project-scoped UI state guidance:
- Persist plugin view layout/state in browser storage using a plugin-owned base key and the shared project-scoped pattern (`kb:${projectId}:${baseKey}`).
- For dependency graph layout, the canonical base key is `fusion-plugin-dependency-graph:positions`.
- Do not persist plugin UI state in task metadata or server-side task records.
--- ---
## 9. Registering Agent Runtimes ## 9. Registering Agent Runtimes

View File

@@ -40,6 +40,20 @@ Features:
![List view](./screenshots/list-view.png) ![List view](./screenshots/list-view.png)
## Graph View
Graph view visualizes task dependencies as an interactive node/edge map.
Navigation:
- Desktop: **Header → More views → Graph**
- Mobile: **MobileNavBar → More → Graph**
Behavior:
- Shows only tasks in `triage`, `todo`, `in-progress`, and `in-review`
- Excludes `done` and `archived`
- Supports pan/zoom, fit-to-graph, dependency-chain highlight, and drag-to-position
- Persists node layout per project using plugin-scoped storage key `fusion-plugin-dependency-graph:positions`
## Chat View ## Chat View
Chat view provides project-scoped conversations with agents. Chat view provides project-scoped conversations with agents.

View File

@@ -256,6 +256,12 @@ function AppInner() {
}); });
const { views: pluginDashboardViews } = usePluginDashboardViews(currentProject?.id); const { views: pluginDashboardViews } = usePluginDashboardViews(currentProject?.id);
const graphPluginTaskView = useMemo(() => {
const graphView = pluginDashboardViews.find(
(entry) => entry.pluginId === "fusion-plugin-dependency-graph" && entry.view.viewId === "graph",
);
return graphView ? (`plugin:${graphView.pluginId}:${graphView.view.viewId}` as const) : null;
}, [pluginDashboardViews]);
// History-aware view change handler — pushes nav entry on back-navigation stack. // History-aware view change handler — pushes nav entry on back-navigation stack.
const handleTaskViewChange = useCallback((newView: TaskView) => { const handleTaskViewChange = useCallback((newView: TaskView) => {
@@ -536,6 +542,10 @@ function AppInner() {
useEffect(() => { useEffect(() => {
if (!settingsLoaded) return; if (!settingsLoaded) return;
if (isPluginViewId(taskView)) return; if (isPluginViewId(taskView)) return;
if (taskView === "graph" && !graphPluginTaskView) {
handleChangeTaskView("board");
return;
}
if (taskView === "skills" && !skillsEnabled) { if (taskView === "skills" && !skillsEnabled) {
handleChangeTaskView("board"); handleChangeTaskView("board");
} }
@@ -557,7 +567,7 @@ function AppInner() {
if (taskView === "research" && !researchEnabled) { if (taskView === "research" && !researchEnabled) {
handleChangeTaskView("board"); handleChangeTaskView("board");
} }
}, [taskView, settingsLoaded, skillsEnabled, insightsEnabled, roadmapEnabled, handleChangeTaskView, agentsEnabled, memoryEnabled, devServerEnabled, researchEnabled]); }, [taskView, settingsLoaded, skillsEnabled, insightsEnabled, roadmapEnabled, handleChangeTaskView, agentsEnabled, memoryEnabled, devServerEnabled, researchEnabled, graphPluginTaskView]);
// Auto-close nodes overlay if feature flag is toggled off while overlay is open // Auto-close nodes overlay if feature flag is toggled off while overlay is open
useEffect(() => { useEffect(() => {
@@ -975,12 +985,14 @@ function AppInner() {
); );
} }
const resolvedPluginTaskView = taskView === "graph" ? graphPluginTaskView : (isPluginViewId(taskView) ? taskView : null);
// Project view // Project view
if (isPluginViewId(taskView)) { if (resolvedPluginTaskView) {
return ( return (
<PageErrorBoundary> <PageErrorBoundary>
<PluginDashboardViewHost <PluginDashboardViewHost
taskView={taskView as `plugin:${string}:${string}`} taskView={resolvedPluginTaskView as `plugin:${string}:${string}`}
context={{ context={{
projectId: currentProject?.id, projectId: currentProject?.id,
tasks: isRemote && remoteData.tasks.length > 0 ? remoteData.tasks : tasks, tasks: isRemote && remoteData.tasks.length > 0 ? remoteData.tasks : tasks,
@@ -993,6 +1005,7 @@ function AppInner() {
onOpenDetail={(value: Task | TaskDetail) => openDetailTask(value)} onOpenDetail={(value: Task | TaskDetail) => openDetailTask(value)}
addToast={addToast} addToast={addToast}
workflowStepNameLookup={workflowStepNameLookup} workflowStepNameLookup={workflowStepNameLookup}
disableDrag={true}
/> />
), ),
}} }}
@@ -1434,7 +1447,7 @@ function AppInner() {
}} }}
pluginDashboardViews={pluginDashboardViews} pluginDashboardViews={pluginDashboardViews}
/> />
{viewMode === "project" && currentProject && taskView !== "chat" && taskView !== "mailbox" && taskView !== "insights" && taskView !== "devserver" && taskView !== "dev-server" && !isPluginViewId(taskView) && ( {viewMode === "project" && currentProject && taskView !== "chat" && taskView !== "mailbox" && taskView !== "insights" && taskView !== "devserver" && taskView !== "dev-server" && taskView !== "graph" && !isPluginViewId(taskView) && (
<QuickChatFAB <QuickChatFAB
projectId={currentProject.id} projectId={currentProject.id}
addToast={addToast} addToast={addToast}

View File

@@ -1159,8 +1159,8 @@ export function Header({
return ( return (
<button <button
key={`${entry.pluginId}:${entry.view.viewId}`} key={`${entry.pluginId}:${entry.view.viewId}`}
className={`view-toggle-btn${view === pluginTaskView ? " active" : ""}`} className={`view-toggle-btn${view === pluginTaskView || (view === "graph" && entry.pluginId === "fusion-plugin-dependency-graph" && entry.view.viewId === "graph") ? " active" : ""}`}
onClick={() => onChangeView(pluginTaskView)} onClick={() => onChangeView(entry.pluginId === "fusion-plugin-dependency-graph" && entry.view.viewId === "graph" ? "graph" : pluginTaskView)}
title={`${entry.view.label} view`} title={`${entry.view.label} view`}
aria-label={`${entry.view.label} view`} aria-label={`${entry.view.label} view`}
aria-pressed={view === pluginTaskView} aria-pressed={view === pluginTaskView}
@@ -1174,7 +1174,7 @@ export function Header({
<> <>
<button <button
ref={viewOverflowTriggerRef} ref={viewOverflowTriggerRef}
className={`view-toggle-btn${["research", "skills", "roadmaps", "insights", "memory", "dev-server", "devserver"].includes(view) || (todosEnabled && todosOpen) || isPluginViewId(view) ? " active" : ""}`} className={`view-toggle-btn${["research", "skills", "roadmaps", "insights", "memory", "dev-server", "devserver", "graph"].includes(view) || (todosEnabled && todosOpen) || isPluginViewId(view) ? " active" : ""}`}
onClick={() => setIsViewOverflowOpen((prev) => !prev)} onClick={() => setIsViewOverflowOpen((prev) => !prev)}
title="More views" title="More views"
aria-label="More views" aria-label="More views"
@@ -1298,9 +1298,9 @@ export function Header({
return ( return (
<button <button
key={`${entry.pluginId}:${entry.view.viewId}`} key={`${entry.pluginId}:${entry.view.viewId}`}
className={`view-toggle-overflow-item${view === pluginTaskView ? " active" : ""}`} className={`view-toggle-overflow-item${view === pluginTaskView || (view === "graph" && entry.pluginId === "fusion-plugin-dependency-graph" && entry.view.viewId === "graph") ? " active" : ""}`}
onClick={() => { onClick={() => {
onChangeView(pluginTaskView); onChangeView(entry.pluginId === "fusion-plugin-dependency-graph" && entry.view.viewId === "graph" ? "graph" : pluginTaskView);
setIsViewOverflowOpen(false); setIsViewOverflowOpen(false);
}} }}
role="menuitem" role="menuitem"

View File

@@ -212,22 +212,11 @@ export function MobileNavBar({
const showRoadmapsTopLevel = roadmapEnabled && (!skillsEnabled || view === "roadmaps"); const showRoadmapsTopLevel = roadmapEnabled && (!skillsEnabled || view === "roadmaps");
const showSkillsTopLevel = skillsEnabled && (!roadmapEnabled || view !== "roadmaps"); const showSkillsTopLevel = skillsEnabled && (!roadmapEnabled || view !== "roadmaps");
const showSkillsInMore = skillsEnabled && !showSkillsTopLevel; const showSkillsInMore = skillsEnabled && !showSkillsTopLevel;
const isDependencyGraphView = (entry: PluginDashboardViewEntry): boolean => (
entry.pluginId === "fusion-plugin-dependency-graph" && entry.view.viewId === "graph"
);
const sortedPrimaryPluginViews = pluginDashboardViews const sortedPrimaryPluginViews = pluginDashboardViews
.filter((entry) => entry.view.placement === "primary") .filter((entry) => entry.view.placement === "primary")
.sort((a, b) => (a.view.order ?? Number.MAX_SAFE_INTEGER) - (b.view.order ?? Number.MAX_SAFE_INTEGER)); .sort((a, b) => (a.view.order ?? Number.MAX_SAFE_INTEGER) - (b.view.order ?? Number.MAX_SAFE_INTEGER));
const dependencyGraphPluginView = pluginDashboardViews.find(isDependencyGraphView) ?? null;
// Keep plugin-provided top-level tabs constrained on mobile so fixed tabs retain
// reasonable touch-target width. Additional primary plugin destinations overflow into More.
// FN-3235: Always surface the dependency graph destination as the first plugin top-level tab
// on mobile so task graph navigation has a clear entry point.
const prioritizedPrimaryPluginViews = dependencyGraphPluginView
? [dependencyGraphPluginView, ...sortedPrimaryPluginViews.filter((entry) => !isDependencyGraphView(entry))]
: sortedPrimaryPluginViews;
const MAX_PRIMARY_PLUGIN_TOP_LEVEL_TABS = 1; const MAX_PRIMARY_PLUGIN_TOP_LEVEL_TABS = 1;
const topLevelPrimaryPluginViews = prioritizedPrimaryPluginViews.slice(0, MAX_PRIMARY_PLUGIN_TOP_LEVEL_TABS); const topLevelPrimaryPluginViews = sortedPrimaryPluginViews.slice(0, MAX_PRIMARY_PLUGIN_TOP_LEVEL_TABS);
const topLevelPluginViewKeys = new Set( const topLevelPluginViewKeys = new Set(
topLevelPrimaryPluginViews.map((entry) => `${entry.pluginId}:${entry.view.viewId}`), topLevelPrimaryPluginViews.map((entry) => `${entry.pluginId}:${entry.view.viewId}`),
); );
@@ -245,6 +234,7 @@ export function MobileNavBar({
|| (todosOpen && todoViewEnabled) || (todosOpen && todoViewEnabled)
|| (view === "roadmaps" && !showRoadmapsTopLevel) || (view === "roadmaps" && !showRoadmapsTopLevel)
|| (view === "skills" && !showSkillsTopLevel) || (view === "skills" && !showSkillsTopLevel)
|| view === "graph"
|| (isPluginViewId(view) && !topLevelPrimaryPluginViews.some((entry) => buildPluginTaskViewId(entry.pluginId, entry.view.viewId) === view)); || (isPluginViewId(view) && !topLevelPrimaryPluginViews.some((entry) => buildPluginTaskViewId(entry.pluginId, entry.view.viewId) === view));
return ( return (
@@ -365,11 +355,11 @@ export function MobileNavBar({
<button <button
key={`${entry.pluginId}:${entry.view.viewId}`} key={`${entry.pluginId}:${entry.view.viewId}`}
type="button" type="button"
className={`mobile-nav-tab${view === pluginTaskView ? " mobile-nav-tab--active" : ""}`} className={`mobile-nav-tab${view === pluginTaskView || (view === "graph" && entry.pluginId === "fusion-plugin-dependency-graph" && entry.view.viewId === "graph") ? " mobile-nav-tab--active" : ""}`}
data-testid={`mobile-nav-tab-plugin-${entry.pluginId}-${entry.view.viewId}`} data-testid={`mobile-nav-tab-plugin-${entry.pluginId}-${entry.view.viewId}`}
role="tab" role="tab"
aria-selected={view === pluginTaskView} aria-selected={view === pluginTaskView || (view === "graph" && entry.pluginId === "fusion-plugin-dependency-graph" && entry.view.viewId === "graph")}
onClick={() => onChangeView(pluginTaskView)} onClick={() => onChangeView(entry.pluginId === "fusion-plugin-dependency-graph" && entry.view.viewId === "graph" ? "graph" : pluginTaskView)}
> >
<PluginIcon /> <PluginIcon />
<span className="mobile-nav-tab-label">{entry.view.label}</span> <span className="mobile-nav-tab-label">{entry.view.label}</span>
@@ -719,7 +709,7 @@ export function MobileNavBar({
type="button" type="button"
className="mobile-more-item" className="mobile-more-item"
data-testid={`mobile-more-item-plugin-${entry.pluginId}-${entry.view.viewId}`} data-testid={`mobile-more-item-plugin-${entry.pluginId}-${entry.view.viewId}`}
onClick={() => handleMoreAction(() => onChangeView(pluginTaskView))} onClick={() => handleMoreAction(() => onChangeView(entry.pluginId === "fusion-plugin-dependency-graph" && entry.view.viewId === "graph" ? "graph" : pluginTaskView))}
> >
<PluginIcon /> <PluginIcon />
<span>{entry.view.label}</span> <span>{entry.view.label}</span>

View File

@@ -274,6 +274,8 @@ interface TaskCardProps {
lastFetchTimeMs?: number; lastFetchTimeMs?: number;
/** Lookup of workflow step IDs to display names, fetched once at board level. */ /** Lookup of workflow step IDs to display names, fetched once at board level. */
workflowStepNameLookup?: ReadonlyMap<string, string>; workflowStepNameLookup?: ReadonlyMap<string, string>;
/** Disable card drag semantics when embedding in custom draggable containers (e.g. dependency graph). */
disableDrag?: boolean;
} }
function areTaskBadgeInfosEqual( function areTaskBadgeInfosEqual(
@@ -415,6 +417,7 @@ function areTaskCardPropsEqual(previous: TaskCardProps, next: TaskCardProps): bo
previous.onOpenMission === next.onOpenMission && previous.onOpenMission === next.onOpenMission &&
previous.onMoveTask === next.onMoveTask && previous.onMoveTask === next.onMoveTask &&
previous.workflowStepNameLookup === next.workflowStepNameLookup && previous.workflowStepNameLookup === next.workflowStepNameLookup &&
previous.disableDrag === next.disableDrag &&
previousTask.id === nextTask.id && previousTask.id === nextTask.id &&
previousTask.title === nextTask.title && previousTask.title === nextTask.title &&
previousTask.description === nextTask.description && previousTask.description === nextTask.description &&
@@ -478,6 +481,7 @@ function TaskCardComponent({
onMoveTask, onMoveTask,
lastFetchTimeMs, lastFetchTimeMs,
workflowStepNameLookup, workflowStepNameLookup,
disableDrag,
}: TaskCardProps) { }: TaskCardProps) {
const [dragging, setDragging] = useState(false); const [dragging, setDragging] = useState(false);
const [fileDragOver, setFileDragOver] = useState(false); const [fileDragOver, setFileDragOver] = useState(false);
@@ -728,7 +732,7 @@ function TaskCardComponent({
const isAwaitingApproval = task.column === "triage" && task.status === "awaiting-approval"; const isAwaitingApproval = task.column === "triage" && task.status === "awaiting-approval";
const isArchived = task.column === "archived"; const isArchived = task.column === "archived";
const isAgentActive = !globalPaused && !queued && !isFailed && !isPaused && !isStuck && !isAwaitingApproval && (task.column === "in-progress" || ACTIVE_STATUSES.has(task.status as string)); const isAgentActive = !globalPaused && !queued && !isFailed && !isPaused && !isStuck && !isAwaitingApproval && (task.column === "in-progress" || ACTIVE_STATUSES.has(task.status as string));
const isDraggable = !queued && !isPaused && !isEditing && !isArchived; // Disable drag during edit or if archived const isDraggable = !disableDrag && !queued && !isPaused && !isEditing && !isArchived; // Disable drag during edit/archived or host embedding
// Check if this card can be edited inline // Check if this card can be edited inline
const canEdit = EDITABLE_COLUMNS.has(task.column) && !isAgentActive && !isPaused && !queued && onUpdateTask; const canEdit = EDITABLE_COLUMNS.has(task.column) && !isAgentActive && !isPaused && !queued && onUpdateTask;

View File

@@ -167,14 +167,14 @@ describe("Header", () => {
expect(screen.getByTestId("view-overflow-todos")).toBeInTheDocument(); expect(screen.getByTestId("view-overflow-todos")).toBeInTheDocument();
}); });
it("renders plugin dashboard views by placement and uses manifest icon metadata", () => { it("renders dependency graph in overflow and uses canonical graph task view", () => {
const onChangeView = vi.fn(); const onChangeView = vi.fn();
renderHeader({ renderHeader({
onChangeView, onChangeView,
pluginDashboardViews: [ pluginDashboardViews: [
{ {
pluginId: "fusion-plugin-dependency-graph", pluginId: "fusion-plugin-dependency-graph",
view: { viewId: "graph", label: "Graph", componentPath: "./GraphView", icon: "Map", placement: "primary" }, view: { viewId: "graph", label: "Graph", componentPath: "./GraphView", icon: "Map", placement: "more" },
}, },
{ {
pluginId: "fusion-plugin-dependency-graph", pluginId: "fusion-plugin-dependency-graph",
@@ -183,10 +183,13 @@ describe("Header", () => {
], ],
}); });
const graphPrimary = screen.getByTestId("view-toggle-plugin-fusion-plugin-dependency-graph-graph"); expect(screen.queryByTestId("view-toggle-plugin-fusion-plugin-dependency-graph-graph")).toBeNull();
expect(graphPrimary.querySelector(".lucide-map")).toBeTruthy();
fireEvent.click(graphPrimary); fireEvent.click(screen.getByTestId("view-toggle-overflow-trigger"));
expect(onChangeView).toHaveBeenCalledWith("plugin:fusion-plugin-dependency-graph:graph"); const graphItem = screen.getByTestId("view-overflow-plugin-fusion-plugin-dependency-graph-graph");
expect(graphItem.querySelector(".lucide-map")).toBeTruthy();
fireEvent.click(graphItem);
expect(onChangeView).toHaveBeenCalledWith("graph");
fireEvent.click(screen.getByTestId("view-toggle-overflow-trigger")); fireEvent.click(screen.getByTestId("view-toggle-overflow-trigger"));
const queueItem = screen.getByTestId("view-overflow-plugin-fusion-plugin-dependency-graph-queue"); const queueItem = screen.getByTestId("view-overflow-plugin-fusion-plugin-dependency-graph-queue");

View File

@@ -121,7 +121,7 @@ describe("MobileNavBar", () => {
expect(onOpenTodos).toHaveBeenCalled(); expect(onOpenTodos).toHaveBeenCalled();
}); });
it("renders dependency graph as a top-level tab and keeps additional plugin views in More", () => { it("keeps dependency graph in More and routes to canonical graph task view", () => {
const props = createDefaultProps(); const props = createDefaultProps();
render( render(
<MobileNavBar <MobileNavBar
@@ -139,14 +139,14 @@ describe("MobileNavBar", () => {
/>, />,
); );
const primaryTab = screen.getByTestId("mobile-nav-tab-plugin-fusion-plugin-dependency-graph-graph"); expect(screen.queryByTestId("mobile-nav-tab-plugin-fusion-plugin-dependency-graph-graph")).toBeNull();
expect(primaryTab.querySelector(".lucide-map")).toBeTruthy();
fireEvent.click(primaryTab);
expect(props.onChangeView).toHaveBeenCalledWith("plugin:fusion-plugin-dependency-graph:graph");
fireEvent.click(screen.getByTestId("mobile-nav-tab-more")); fireEvent.click(screen.getByTestId("mobile-nav-tab-more"));
expect(screen.queryByTestId("mobile-more-item-plugin-fusion-plugin-dependency-graph-graph")).toBeNull(); const graphItem = screen.getByTestId("mobile-more-item-plugin-fusion-plugin-dependency-graph-graph");
fireEvent.click(graphItem);
expect(props.onChangeView).toHaveBeenCalledWith("graph");
fireEvent.click(screen.getByTestId("mobile-nav-tab-more"));
const overflowItem = screen.getByTestId("mobile-more-item-plugin-fusion-plugin-dependency-graph-queue"); const overflowItem = screen.getByTestId("mobile-more-item-plugin-fusion-plugin-dependency-graph-queue");
expect(overflowItem.querySelector(".lucide-workflow")).toBeTruthy(); expect(overflowItem.querySelector(".lucide-workflow")).toBeTruthy();
fireEvent.click(overflowItem); fireEvent.click(overflowItem);
@@ -177,11 +177,11 @@ describe("MobileNavBar", () => {
expect(screen.getByTestId("mobile-more-item-plugin-fusion-plugin-dependency-graph-queue")).toBeDefined(); expect(screen.getByTestId("mobile-more-item-plugin-fusion-plugin-dependency-graph-queue")).toBeDefined();
}); });
it("marks dependency graph plugin tab active when viewing its canonical plugin task-view", () => { it("marks More active when current view is graph", () => {
render( render(
<MobileNavBar <MobileNavBar
{...createDefaultProps()} {...createDefaultProps()}
view="plugin:fusion-plugin-dependency-graph:graph" view="graph"
pluginDashboardViews={[ pluginDashboardViews={[
{ {
pluginId: "fusion-plugin-dependency-graph", pluginId: "fusion-plugin-dependency-graph",
@@ -195,8 +195,7 @@ describe("MobileNavBar", () => {
/>, />,
); );
expect(screen.getByTestId("mobile-nav-tab-plugin-fusion-plugin-dependency-graph-graph").className).toContain("mobile-nav-tab--active"); expect(screen.getByTestId("mobile-nav-tab-more").className).toContain("mobile-nav-tab--active");
expect(screen.getByTestId("mobile-nav-tab-more").className).not.toContain("mobile-nav-tab--active");
}); });
it("marks More active when current plugin view is overflow-only", () => { it("marks More active when current plugin view is overflow-only", () => {
@@ -218,7 +217,7 @@ describe("MobileNavBar", () => {
); );
expect(screen.getByTestId("mobile-nav-tab-more").className).toContain("mobile-nav-tab--active"); expect(screen.getByTestId("mobile-nav-tab-more").className).toContain("mobile-nav-tab--active");
expect(screen.getByTestId("mobile-nav-tab-plugin-fusion-plugin-dependency-graph-graph").className).not.toContain("mobile-nav-tab--active"); expect(screen.queryByTestId("mobile-nav-tab-plugin-fusion-plugin-dependency-graph-graph")).toBeNull();
}); });
it("active tab is highlighted for mailbox", () => { it("active tab is highlighted for mailbox", () => {

View File

@@ -59,6 +59,12 @@ describe("TaskCard", () => {
expect(screen.getByText("FN-001")).toBeDefined(); expect(screen.getByText("FN-001")).toBeDefined();
}); });
it("disables native card dragging when disableDrag is true", () => {
const { container } = render(<TaskCard task={makeTask()} onOpenDetail={noop} addToast={noop} disableDrag={true} />);
const card = container.querySelector(".card") as HTMLElement;
expect(card.getAttribute("draggable")).toBe("false");
});
it("clicking PR badge link does not open the task detail modal", () => { it("clicking PR badge link does not open the task detail modal", () => {
const onOpenDetail = vi.fn(); const onOpenDetail = vi.fn();
render( render(

View File

@@ -307,6 +307,28 @@ describe("useViewState", () => {
}); });
}); });
it("restores and persists graph taskView using scoped storage", async () => {
localStorage.setItem("kb:proj_123:kb-dashboard-task-view", "graph");
const { result } = renderHook(() =>
useViewState(
createOptions({
currentProject: PROJECT,
}),
),
);
await waitFor(() => {
expect(result.current.taskView).toBe("graph");
});
await act(async () => {
result.current.setTaskView("graph");
});
expect(localStorage.getItem("kb:proj_123:kb-dashboard-task-view")).toBe("graph");
});
it("restores and persists plugin task views using the canonical composite key", async () => { it("restores and persists plugin task views using the canonical composite key", async () => {
localStorage.setItem("kb:proj_123:kb-dashboard-task-view", "plugin:fusion-plugin-dependency-graph:graph"); localStorage.setItem("kb:proj_123:kb-dashboard-task-view", "plugin:fusion-plugin-dependency-graph:graph");

View File

@@ -5,13 +5,14 @@ import { getScopedItem, setScopedItem } from "../utils/projectStorage";
import { isPluginViewId } from "../plugins/pluginViewRegistry"; import { isPluginViewId } from "../plugins/pluginViewRegistry";
export type ViewMode = "overview" | "project"; export type ViewMode = "overview" | "project";
export type BuiltInTaskView = "board" | "list" | "agents" | "missions" | "chat" | "documents" | "research" | "roadmaps" | "skills" | "mailbox" | "insights" | "memory" | "devserver" | "dev-server"; export type BuiltInTaskView = "board" | "list" | "graph" | "agents" | "missions" | "chat" | "documents" | "research" | "roadmaps" | "skills" | "mailbox" | "insights" | "memory" | "devserver" | "dev-server";
export type PluginTaskView = `plugin:${string}:${string}`; export type PluginTaskView = `plugin:${string}:${string}`;
export type TaskView = BuiltInTaskView | PluginTaskView; export type TaskView = BuiltInTaskView | PluginTaskView;
const BUILT_IN_TASK_VIEWS: readonly BuiltInTaskView[] = [ const BUILT_IN_TASK_VIEWS: readonly BuiltInTaskView[] = [
"board", "board",
"list", "list",
"graph",
"agents", "agents",
"missions", "missions",
"chat", "chat",