diff --git a/src/app/api/workflow-load/route.ts b/src/app/api/workflow-load/route.ts
new file mode 100644
index 00000000..baa968b2
--- /dev/null
+++ b/src/app/api/workflow-load/route.ts
@@ -0,0 +1,54 @@
+import { NextRequest, NextResponse } from "next/server";
+import * as fs from "fs/promises";
+import * as path from "path";
+
+// POST: Load workflow JSON from disk by path
+export async function POST(request: NextRequest) {
+ try {
+ const body = await request.json();
+ const { directoryPath, filename } = body;
+
+ if (!directoryPath || !filename) {
+ return NextResponse.json(
+ { success: false, error: "Missing required fields: directoryPath and filename" },
+ { status: 400 }
+ );
+ }
+
+ // Same sanitization as the save route
+ const safeName = filename.replace(/[^a-zA-Z0-9-_]/g, "_");
+ const filePath = path.join(directoryPath, `${safeName}.json`);
+
+ // Prevent path traversal
+ const resolvedDir = path.resolve(directoryPath);
+ const resolvedFile = path.resolve(filePath);
+ if (!resolvedFile.startsWith(resolvedDir)) {
+ return NextResponse.json(
+ { success: false, error: "Invalid file path" },
+ { status: 400 }
+ );
+ }
+
+ const content = await fs.readFile(resolvedFile, "utf-8");
+ const workflow = JSON.parse(content);
+
+ return NextResponse.json({ success: true, workflow });
+ } catch (error) {
+ const isNotFound =
+ error instanceof Error &&
+ "code" in error &&
+ (error as NodeJS.ErrnoException).code === "ENOENT";
+
+ return NextResponse.json(
+ {
+ success: false,
+ error: isNotFound
+ ? "Workflow file not found"
+ : error instanceof Error
+ ? error.message
+ : "Failed to load workflow",
+ },
+ { status: isNotFound ? 404 : 500 }
+ );
+ }
+}
diff --git a/src/components/Header.tsx b/src/components/Header.tsx
index 1a0e58f6..e71f0f91 100644
--- a/src/components/Header.tsx
+++ b/src/components/Header.tsx
@@ -72,6 +72,8 @@ export function Header() {
revertToSnapshot,
shortcutsDialogOpen,
setShortcutsDialogOpen,
+ setShowDashboard,
+ autoSaveEnabled,
} = useWorkflowStore();
const [showProjectModal, setShowProjectModal] = useState(false);
@@ -222,6 +224,15 @@ export function Header() {
Node Banana
+
{isProjectConfigured ? (
@@ -363,12 +374,18 @@ export function Header() {
)}
-
+
{isProjectConfigured ? (
isSaving ? (
- "Saving..."
+ <>
+
+ Saving...
+ >
) : lastSavedAt ? (
- `Saved ${formatTime(lastSavedAt)}`
+ <>
+ {autoSaveEnabled && }
+ Saved {formatTime(lastSavedAt)}
+ >
) : (
"Not saved"
)
diff --git a/src/components/WorkflowCanvas.tsx b/src/components/WorkflowCanvas.tsx
index 2ae6d4fd..d66fa3ea 100644
--- a/src/components/WorkflowCanvas.tsx
+++ b/src/components/WorkflowCanvas.tsx
@@ -56,6 +56,7 @@ import { detectAndSplitGrid } from "@/utils/gridSplitter";
import { logger } from "@/utils/logger";
import { WelcomeModal } from "./quickstart";
import { ProjectSetupModal } from "./ProjectSetupModal";
+import { ProjectDashboard } from "./dashboard/ProjectDashboard";
import { ChatPanel } from "./ChatPanel";
import { EditOperation } from "@/lib/chat/editOperations";
import { stripBinaryData } from "@/lib/chat/contextBuilder";
@@ -239,7 +240,7 @@ const findScrollableAncestor = (target: HTMLElement, deltaX: number, deltaY: num
};
export function WorkflowCanvas() {
- const { nodes, edges, groups, onNodesChange, onEdgesChange, onConnect, addNode, updateNodeData, loadWorkflow, getNodeById, addToGlobalHistory, setNodeGroupId, executeWorkflow, isModalOpen, showQuickstart, setShowQuickstart, navigationTarget, setNavigationTarget, captureSnapshot, applyEditOperations, setWorkflowMetadata, canvasNavigationSettings, setShortcutsDialogOpen } =
+ const { nodes, edges, groups, onNodesChange, onEdgesChange, onConnect, addNode, updateNodeData, loadWorkflow, getNodeById, addToGlobalHistory, setNodeGroupId, executeWorkflow, isModalOpen, showQuickstart, setShowQuickstart, showDashboard, setShowDashboard, navigationTarget, setNavigationTarget, captureSnapshot, applyEditOperations, setWorkflowMetadata, canvasNavigationSettings, setShortcutsDialogOpen } =
useWorkflowStore();
const { screenToFlowPosition, getViewport, zoomIn, zoomOut, setViewport, setCenter } = useReactFlow();
const { show: showToast } = useToast();
@@ -255,6 +256,13 @@ export function WorkflowCanvas() {
// Detect if canvas is empty for showing quickstart
const isCanvasEmpty = nodes.length === 0;
+ // Listen for dashboard:new-project event to open new project setup
+ useEffect(() => {
+ const handler = () => setShowNewProjectSetup(true);
+ window.addEventListener("dashboard:new-project", handler);
+ return () => window.removeEventListener("dashboard:new-project", handler);
+ }, []);
+
// Handle comment navigation - center viewport on target node
useEffect(() => {
if (navigationTarget) {
@@ -1605,20 +1613,8 @@ export function WorkflowCanvas() {
)}
- {/* Welcome Modal */}
- {isCanvasEmpty && showQuickstart && (
- {
- await loadWorkflow(workflow);
- setShowQuickstart(false);
- }}
- onClose={() => setShowQuickstart(false)}
- onNewProject={() => {
- setShowQuickstart(false);
- setShowNewProjectSetup(true);
- }}
- />
- )}
+ {/* Project Dashboard */}
+ {showDashboard && }
{/* New Project Setup Modal */}
{showNewProjectSetup && (
@@ -1628,10 +1624,11 @@ export function WorkflowCanvas() {
onSave={(id, name, directoryPath) => {
setWorkflowMetadata(id, name, directoryPath);
setShowNewProjectSetup(false);
+ setShowDashboard(false);
}}
onClose={() => {
setShowNewProjectSetup(false);
- setShowQuickstart(true);
+ setShowDashboard(true);
}}
/>
)}
@@ -1773,6 +1770,20 @@ export function WorkflowCanvas() {
{/* Global image history */}
+ {/* Floating projects button */}
+ {!showDashboard && (
+
+ )}
+
{/* Chat toggle button - hidden for now */}
{/* Chat panel */}
diff --git a/src/components/dashboard/ProjectCard.tsx b/src/components/dashboard/ProjectCard.tsx
new file mode 100644
index 00000000..95fd88b3
--- /dev/null
+++ b/src/components/dashboard/ProjectCard.tsx
@@ -0,0 +1,282 @@
+"use client";
+
+import { useState, useCallback, useMemo } from "react";
+import type { DashboardProject } from "@/store/utils/localStorage";
+
+function formatRelativeTime(timestamp: number | undefined | null): string {
+ if (!timestamp) return "Never saved";
+ const seconds = Math.floor((Date.now() - timestamp) / 1000);
+ if (seconds < 60) return "Just now";
+ const minutes = Math.floor(seconds / 60);
+ if (minutes < 60) return `${minutes}m ago`;
+ const hours = Math.floor(minutes / 60);
+ if (hours < 24) return `${hours}h ago`;
+ const days = Math.floor(hours / 24);
+ if (days < 30) return `${days}d ago`;
+ return new Date(timestamp).toLocaleDateString();
+}
+
+function formatCost(cost: number): string {
+ if (cost === 0) return "";
+ return `$${cost.toFixed(2)}`;
+}
+
+function truncatePath(dirPath: string, maxLen: number = 50): string {
+ if (dirPath.length <= maxLen) return dirPath;
+ const parts = dirPath.split("/");
+ if (parts.length <= 2) return "..." + dirPath.slice(-maxLen);
+ return ".../" + parts.slice(-2).join("/");
+}
+
+// Derive a "workflow type" label from the node composition
+function deriveWorkflowType(summary?: Record): { label: string; color: string } | null {
+ if (!summary) return null;
+ const has = (k: string) => (summary[k] ?? 0) > 0;
+
+ if (has("generateVideo")) return { label: "Video", color: "text-purple-400 bg-purple-500/15 border-purple-500/20" };
+ if (has("generate3d")) return { label: "3D", color: "text-orange-400 bg-orange-500/15 border-orange-500/20" };
+ if (has("generateAudio")) return { label: "Audio", color: "text-green-400 bg-green-500/15 border-green-500/20" };
+ if (has("nanoBanana")) return { label: "Image Gen", color: "text-yellow-400 bg-yellow-500/15 border-yellow-500/20" };
+ if (has("llmGenerate")) return { label: "LLM", color: "text-blue-400 bg-blue-500/15 border-blue-500/20" };
+ if (has("annotation")) return { label: "Annotation", color: "text-cyan-400 bg-cyan-500/15 border-cyan-500/20" };
+ return null;
+}
+
+// Small node-type icon pills
+const NODE_TYPE_ICONS: Record = {
+ nanoBanana: {
+ label: "Generate",
+ icon: (
+
+ ),
+ },
+ generateVideo: {
+ label: "Video",
+ icon: (
+
+ ),
+ },
+ llmGenerate: {
+ label: "LLM",
+ icon: (
+
+ ),
+ },
+ prompt: {
+ label: "Prompts",
+ icon: (
+
+ ),
+ },
+ imageInput: {
+ label: "Images",
+ icon: (
+
+ ),
+ },
+ annotation: {
+ label: "Draw",
+ icon: (
+
+ ),
+ },
+ generateAudio: {
+ label: "Audio",
+ icon: (
+
+ ),
+ },
+ generate3d: {
+ label: "3D",
+ icon: (
+
+ ),
+ },
+};
+
+// Ordered by visual priority
+const NODE_TYPE_DISPLAY_ORDER = [
+ "nanoBanana", "generateVideo", "generate3d", "generateAudio",
+ "llmGenerate", "annotation", "prompt", "imageInput",
+];
+
+interface ProjectCardProps {
+ project: DashboardProject;
+ isCurrent: boolean;
+ onOpen: (workflowId: string) => void;
+ onDelete: (workflowId: string) => void;
+}
+
+export function ProjectCard({ project, isCurrent, onOpen, onDelete }: ProjectCardProps) {
+ const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
+
+ const handleDelete = useCallback((e: React.MouseEvent) => {
+ e.stopPropagation();
+ if (showDeleteConfirm) {
+ onDelete(project.workflowId);
+ setShowDeleteConfirm(false);
+ } else {
+ setShowDeleteConfirm(true);
+ }
+ }, [showDeleteConfirm, onDelete, project.workflowId]);
+
+ const handleCancelDelete = useCallback((e: React.MouseEvent) => {
+ e.stopPropagation();
+ setShowDeleteConfirm(false);
+ }, []);
+
+ const workflowType = useMemo(
+ () => deriveWorkflowType(project.nodeTypeSummary),
+ [project.nodeTypeSummary]
+ );
+
+ // Get node type pills to show (top 4 by display order)
+ const nodeTypePills = useMemo(() => {
+ if (!project.nodeTypeSummary) return [];
+ return NODE_TYPE_DISPLAY_ORDER
+ .filter((t) => (project.nodeTypeSummary![t] ?? 0) > 0)
+ .slice(0, 4)
+ .map((t) => ({
+ type: t,
+ count: project.nodeTypeSummary![t],
+ ...NODE_TYPE_ICONS[t],
+ }));
+ }, [project.nodeTypeSummary]);
+
+ return (
+
+ );
+}
diff --git a/src/components/dashboard/ProjectDashboard.tsx b/src/components/dashboard/ProjectDashboard.tsx
new file mode 100644
index 00000000..f68e808e
--- /dev/null
+++ b/src/components/dashboard/ProjectDashboard.tsx
@@ -0,0 +1,310 @@
+"use client";
+
+import { useState, useCallback, useRef, useMemo } from "react";
+import { useWorkflowStore, WorkflowFile } from "@/store/workflowStore";
+import { getAllProjectsForDashboard } from "@/store/utils/localStorage";
+import { ProjectCard } from "./ProjectCard";
+import { TemplateExplorerView } from "../quickstart/TemplateExplorerView";
+import { PromptWorkflowView } from "../quickstart/PromptWorkflowView";
+
+type DashboardView = "main" | "templates" | "vibe";
+
+export function ProjectDashboard() {
+ const {
+ workflowId: currentWorkflowId,
+ hasUnsavedChanges,
+ setShowDashboard,
+ openProject,
+ deleteProject,
+ loadWorkflow,
+ setShowQuickstart,
+ } = useWorkflowStore();
+
+ const [view, setView] = useState("main");
+ const [refreshKey, setRefreshKey] = useState(0);
+ const fileInputRef = useRef(null);
+
+ const projects = useMemo(
+ () => getAllProjectsForDashboard(),
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ [refreshKey]
+ );
+
+ const confirmUnsavedChanges = useCallback((): boolean => {
+ if (!hasUnsavedChanges) return true;
+ return window.confirm(
+ "You have unsaved changes. Are you sure you want to switch projects? Your unsaved changes will be lost."
+ );
+ }, [hasUnsavedChanges]);
+
+ const handleOpenProject = useCallback(
+ async (wfId: string) => {
+ if (wfId === currentWorkflowId) {
+ setShowDashboard(false);
+ return;
+ }
+ if (!confirmUnsavedChanges()) return;
+ await openProject(wfId);
+ },
+ [currentWorkflowId, confirmUnsavedChanges, openProject, setShowDashboard]
+ );
+
+ const handleDeleteProject = useCallback(
+ (wfId: string) => {
+ deleteProject(wfId);
+ setRefreshKey((k) => k + 1);
+ },
+ [deleteProject]
+ );
+
+ const handleNewProject = useCallback(() => {
+ if (!confirmUnsavedChanges()) return;
+ setShowDashboard(false);
+ setShowQuickstart(false);
+ useWorkflowStore.getState().clearWorkflow();
+ window.dispatchEvent(new CustomEvent("dashboard:new-project"));
+ }, [confirmUnsavedChanges, setShowDashboard, setShowQuickstart]);
+
+ const handleLoadFile = useCallback(() => {
+ fileInputRef.current?.click();
+ }, []);
+
+ const handleFileChange = useCallback(
+ (e: React.ChangeEvent) => {
+ const file = e.target.files?.[0];
+ if (!file) return;
+ if (!confirmUnsavedChanges()) {
+ e.target.value = "";
+ return;
+ }
+
+ const reader = new FileReader();
+ reader.onload = async (event) => {
+ try {
+ const workflow = JSON.parse(event.target?.result as string) as WorkflowFile;
+ if (workflow.version && workflow.nodes && workflow.edges) {
+ await loadWorkflow(workflow);
+ setShowDashboard(false);
+ } else {
+ alert("Invalid workflow file format");
+ }
+ } catch {
+ alert("Failed to parse workflow file");
+ }
+ };
+ reader.readAsText(file);
+ e.target.value = "";
+ },
+ [confirmUnsavedChanges, loadWorkflow, setShowDashboard]
+ );
+
+ const handleWorkflowGenerated = useCallback(
+ async (workflow: WorkflowFile) => {
+ await loadWorkflow(workflow);
+ setShowDashboard(false);
+ },
+ [loadWorkflow, setShowDashboard]
+ );
+
+ const dialogWidth = view === "templates" ? "max-w-6xl" : "max-w-5xl";
+ const dialogHeight = view === "templates" ? "max-h-[85vh]" : "max-h-[85vh]";
+
+ return (
+ e.stopPropagation()}
+ >
+
+ {view === "templates" && (
+
setView("main")}
+ onWorkflowSelected={handleWorkflowGenerated}
+ />
+ )}
+ {view === "vibe" && (
+ setView("main")}
+ onWorkflowGenerated={handleWorkflowGenerated}
+ />
+ )}
+ {view === "main" && (
+
+ {/* Left column — Branding + links */}
+
+
+
+

+
+ Node Banana
+
+
+
+
+ A node based workflow editor for AI image generation. Connect nodes to build pipelines.
+
+
+ {/* Quick stats */}
+ {projects.length > 0 && (
+
+
+
+
{projects.length}
+
Projects
+
+
+
+ {projects.reduce((sum, p) => sum + (p.nodeCount ?? 0), 0)}
+
+
Total Nodes
+
+
+
+ )}
+
+
+
+
+ {/* Right column — Projects + actions */}
+
+ {/* Header with title + close */}
+
+
Your Projects
+ {currentWorkflowId && (
+
setShowDashboard(false)}
+ className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-neutral-400 hover:text-neutral-200 bg-neutral-800/50 hover:bg-neutral-700/50 border border-neutral-700/50 rounded-lg transition-colors"
+ title="Return to current project"
+ >
+
+ Back to project
+
+ )}
+
+
+ {/* Action buttons row */}
+
+
+
+ New Project
+
+
+
+ Load File
+
+
+
setView("templates")}
+ className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-neutral-300 hover:text-neutral-100 bg-neutral-800/80 hover:bg-neutral-700/80 border border-neutral-700/50 rounded-lg transition-colors"
+ >
+
+ Templates
+
+
setView("vibe")}
+ className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-neutral-300 hover:text-neutral-100 bg-neutral-800/80 hover:bg-neutral-700/80 border border-neutral-700/50 rounded-lg transition-colors"
+ >
+
+ Prompt
+
+ Beta
+
+
+
+
+ {/* Projects list */}
+
+ {projects.length === 0 ? (
+
+
+
No projects yet
+
+ Create a new project, load an existing workflow file, or start from a template to get going.
+
+
+ ) : (
+
+ {projects.map((project) => (
+
+ ))}
+
+ )}
+
+
+
+ )}
+
+
+ {/* Hidden file input for loading workflows */}
+
+
+ );
+}
diff --git a/src/store/utils/localStorage.ts b/src/store/utils/localStorage.ts
index eb9500eb..ec5c6464 100644
--- a/src/store/utils/localStorage.ts
+++ b/src/store/utils/localStorage.ts
@@ -60,10 +60,72 @@ export const loadSaveConfigs = (): Record => {
export const saveSaveConfig = (config: WorkflowSaveConfig): void => {
if (typeof window === "undefined") return;
const configs = loadSaveConfigs();
- configs[config.workflowId] = config;
+ const existing = configs[config.workflowId];
+ const now = Date.now();
+ configs[config.workflowId] = {
+ ...config,
+ createdAt: config.createdAt ?? existing?.createdAt ?? now,
+ updatedAt: now,
+ };
localStorage.setItem(STORAGE_KEY, JSON.stringify(configs));
};
+export const deleteSaveConfig = (workflowId: string): void => {
+ if (typeof window === "undefined") return;
+ const configs = loadSaveConfigs();
+ delete configs[workflowId];
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(configs));
+ // Clean up cost data
+ const costStored = localStorage.getItem(COST_DATA_STORAGE_KEY);
+ if (costStored) {
+ try {
+ const allCosts: Record = JSON.parse(costStored);
+ delete allCosts[workflowId];
+ localStorage.setItem(COST_DATA_STORAGE_KEY, JSON.stringify(allCosts));
+ } catch { /* ignore */ }
+ }
+};
+
+export interface DashboardProject {
+ workflowId: string;
+ name: string;
+ directoryPath: string;
+ lastSavedAt: number | null;
+ createdAt?: number;
+ updatedAt?: number;
+ nodeCount?: number;
+ edgeCount?: number;
+ nodeTypeSummary?: Record;
+ primaryModel?: string;
+ incurredCost: number;
+}
+
+export const getAllProjectsForDashboard = (): DashboardProject[] => {
+ if (typeof window === "undefined") return [];
+ const configs = loadSaveConfigs();
+ let allCosts: Record = {};
+ const costStored = localStorage.getItem(COST_DATA_STORAGE_KEY);
+ if (costStored) {
+ try { allCosts = JSON.parse(costStored); } catch { /* ignore */ }
+ }
+
+ return Object.values(configs)
+ .map((config) => ({
+ workflowId: config.workflowId,
+ name: config.name,
+ directoryPath: config.directoryPath,
+ lastSavedAt: config.lastSavedAt,
+ createdAt: config.createdAt,
+ updatedAt: config.updatedAt,
+ nodeCount: config.nodeCount,
+ edgeCount: config.edgeCount,
+ nodeTypeSummary: config.nodeTypeSummary,
+ primaryModel: config.primaryModel,
+ incurredCost: allCosts[config.workflowId]?.incurredCost ?? 0,
+ }))
+ .sort((a, b) => (b.updatedAt ?? b.lastSavedAt ?? 0) - (a.updatedAt ?? a.lastSavedAt ?? 0));
+};
+
// Cost data helpers
export const loadWorkflowCostData = (workflowId: string): WorkflowCostData | null => {
if (typeof window === "undefined") return null;
diff --git a/src/store/workflowStore.ts b/src/store/workflowStore.ts
index 8df3f97e..bf0ddf67 100644
--- a/src/store/workflowStore.ts
+++ b/src/store/workflowStore.ts
@@ -31,6 +31,7 @@ import { EditOperation, applyEditOperations as executeEditOps } from "@/lib/chat
import {
loadSaveConfigs,
saveSaveConfig,
+ deleteSaveConfig,
loadWorkflowCostData,
saveWorkflowCostData,
getProviderSettings,
@@ -209,9 +210,15 @@ interface WorkflowStore {
openModalCount: number;
isModalOpen: boolean;
showQuickstart: boolean;
+ showDashboard: boolean;
incrementModalCount: () => void;
decrementModalCount: () => void;
setShowQuickstart: (show: boolean) => void;
+ setShowDashboard: (show: boolean) => void;
+
+ // Dashboard actions
+ openProject: (workflowId: string) => Promise;
+ deleteProject: (workflowId: string) => void;
// Execution
isRunning: boolean;
@@ -378,6 +385,7 @@ export const useWorkflowStore = create((set, get) => ({
openModalCount: 0,
isModalOpen: false,
showQuickstart: true,
+ showDashboard: true,
isRunning: false,
currentNodeIds: [], // Changed from currentNodeId for parallel execution
pausedAtNodeId: null,
@@ -447,6 +455,47 @@ export const useWorkflowStore = create((set, get) => ({
set({ showQuickstart: show });
},
+ setShowDashboard: (show: boolean) => {
+ set({ showDashboard: show });
+ },
+
+ openProject: async (workflowId: string) => {
+ const configs = loadSaveConfigs();
+ const config = configs[workflowId];
+ if (!config) return;
+
+ try {
+ const response = await fetch("/api/workflow-load", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ directoryPath: config.directoryPath,
+ filename: config.name,
+ }),
+ });
+ const result = await response.json();
+ if (result.success && result.workflow) {
+ await get().loadWorkflow(result.workflow, config.directoryPath);
+ set({ showDashboard: false, showQuickstart: false });
+ } else {
+ useToast.getState().show(`Failed to load project: ${result.error || "Unknown error"}`, "error");
+ }
+ } catch (error) {
+ useToast.getState().show(
+ `Failed to load project: ${error instanceof Error ? error.message : "Unknown error"}`,
+ "error"
+ );
+ }
+ },
+
+ deleteProject: (workflowId: string) => {
+ deleteSaveConfig(workflowId);
+ // If the deleted project is currently loaded, clear it
+ if (get().workflowId === workflowId) {
+ get().clearWorkflow();
+ }
+ },
+
addNode: (type: NodeType, position: XYPosition, initialData?: Partial) => {
const id = `${type}-${++nodeIdCounter}`;
@@ -1806,6 +1855,23 @@ export const useWorkflowStore = create((set, get) => ({
});
}
+ // Compute summary metadata for dashboard
+ const currentNodesSnap = get().nodes;
+ const nodeTypeSummary: Record = {};
+ let primaryModel: string | undefined;
+ const modelCounts: Record = {};
+ for (const node of currentNodesSnap) {
+ const t = node.type || "unknown";
+ nodeTypeSummary[t] = (nodeTypeSummary[t] || 0) + 1;
+ const data = node.data as Record;
+ const sel = data.selectedModel as { displayName?: string } | undefined;
+ if (sel?.displayName) {
+ modelCounts[sel.displayName] = (modelCounts[sel.displayName] || 0) + 1;
+ }
+ }
+ const topModel = Object.entries(modelCounts).sort((a, b) => b[1] - a[1])[0];
+ if (topModel) primaryModel = topModel[0];
+
// Update localStorage
saveSaveConfig({
workflowId,
@@ -1814,6 +1880,10 @@ export const useWorkflowStore = create((set, get) => ({
generationsPath: get().generationsPath,
lastSavedAt: timestamp,
useExternalImageStorage,
+ nodeCount: currentNodesSnap.length,
+ edgeCount: get().edges.length,
+ nodeTypeSummary,
+ primaryModel,
});
return true;
diff --git a/src/types/workflow.ts b/src/types/workflow.ts
index 4d4d1b5c..49ea432c 100644
--- a/src/types/workflow.ts
+++ b/src/types/workflow.ts
@@ -24,6 +24,12 @@ export interface WorkflowSaveConfig {
generationsPath: string | null;
lastSavedAt: number | null;
useExternalImageStorage?: boolean; // Whether to store images as files vs embedded base64
+ createdAt?: number; // Set on first save
+ updatedAt?: number; // Set on every save
+ nodeCount?: number; // Snapshot at save time
+ edgeCount?: number; // Snapshot at save time
+ nodeTypeSummary?: Record; // e.g. { nanoBanana: 2, prompt: 3 }
+ primaryModel?: string; // Display name of most-used generation model
}
// Cost tracking data stored per-workflow in localStorage