Files
kandev/apps/web/hooks/domains/settings/use-workflow-settings.ts
T
2026-05-17 20:16:15 +01:00

153 lines
5.8 KiB
TypeScript

"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { useAppStore } from "@/components/state-provider";
import { workflowId, workspaceId as toWorkspaceId, type Workflow } from "@/lib/types/http";
/**
* Manages workflow list state for the settings page, synced with WS events
* from the Zustand store. Supports local edits (dirty tracking) and temp drafts.
*
* `workspaceId` scopes the visible workflows to the current workspace so that
* stale entries from previously visited workspaces (still cached in the global
* Zustand store) don't leak into another workspace's settings page.
*/
export function useWorkflowSettings(initialWorkflows: Workflow[], workspaceId?: string) {
const storeWorkflows = useAppStore((state) => state.workflows.items);
// Hidden workflows (e.g. the system "Improve Kandev" template) are loaded
// into the global store with `includeHidden: true` so the kanban can resolve
// them when a task references one, but they must never surface in the
// settings management UI. Filter them out at the store boundary so all
// downstream merging logic remains hidden-agnostic.
const scopedStoreWorkflows = useMemo(() => {
const visible = storeWorkflows.filter((w) => !w.hidden);
return workspaceId ? visible.filter((w) => w.workspaceId === workspaceId) : visible;
}, [storeWorkflows, workspaceId]);
const [workflowItems, setWorkflowItems] = useState<Workflow[]>(initialWorkflows);
const [savedWorkflowItems, setSavedWorkflowItems] = useState<Workflow[]>(initialWorkflows);
// Track all IDs we've ever seen from SSR props so we only add genuinely new ones
// (not re-add workflows the user deleted locally).
const seenInitialIdsRef = useRef<Set<string>>(new Set(initialWorkflows.map((w) => w.id)));
// Merge new workflows from SSR props (e.g. after router.refresh() following import).
// useState ignores updated initialWorkflows on re-render, so we sync manually.
useEffect(() => {
const seen = seenInitialIdsRef.current;
const newWorkflows = initialWorkflows.filter((w) => !seen.has(w.id));
if (newWorkflows.length === 0) return;
for (const w of newWorkflows) seen.add(w.id);
setWorkflowItems((prev) => {
const localIds = new Set(prev.map((w) => w.id));
const toAdd = newWorkflows.filter((w) => !localIds.has(w.id));
if (toAdd.length === 0) return prev;
return [...prev, ...toAdd];
});
setSavedWorkflowItems((prev) => {
const localIds = new Set(prev.map((w) => w.id));
const toAdd = newWorkflows.filter((w) => !localIds.has(w.id));
if (toAdd.length === 0) return prev;
return [...prev, ...toAdd];
});
}, [initialWorkflows]);
// Track which IDs the store has previously reported so we only remove
// workflows that were actually deleted via WS, not ones the store never knew about.
const prevStoreIdsRef = useRef<Set<string>>(new Set());
useEffect(() => {
const currentStoreIds = new Set(scopedStoreWorkflows.map((w) => w.id));
const prevStoreIds = prevStoreIdsRef.current;
// IDs that were in the store last render but are gone now → actually deleted via WS.
const deletedIds = new Set([...prevStoreIds].filter((id) => !currentStoreIds.has(id)));
prevStoreIdsRef.current = currentStoreIds;
const newFromStore = (prev: Workflow[]) => {
const localIds = new Set(prev.map((w) => w.id));
// Don't add workflows from store for workspaces where we have temp (pending create) workflows.
// This prevents race conditions where WS event arrives before the create API callback.
const tempWorkspaceIds = new Set(
prev.filter((w) => w.id.startsWith("temp-")).map((w) => w.workspace_id),
);
return scopedStoreWorkflows
.filter(
(sw) =>
!localIds.has(workflowId(sw.id)) &&
!tempWorkspaceIds.has(toWorkspaceId(sw.workspaceId)),
)
.map((sw) => storeItemToWorkflow(sw));
};
setWorkflowItems((prev) => {
const toAdd = newFromStore(prev);
// Only remove workflows the store explicitly deleted, keep everything else.
const filtered = prev.filter((w) => !deletedIds.has(w.id));
const updated = filtered.map((w) => {
if (w.id.startsWith("temp-")) return w;
const sw = scopedStoreWorkflows.find((s) => s.id === w.id);
if (sw && sw.name !== w.name) return { ...w, name: sw.name };
return w;
});
if (
toAdd.length === 0 &&
updated.length === prev.length &&
updated.every((w, i) => w === prev[i])
) {
return prev;
}
return [...toAdd, ...updated];
});
setSavedWorkflowItems((prev) => {
const toAdd = newFromStore(prev);
const filtered = prev.filter((w) => !deletedIds.has(w.id));
if (toAdd.length === 0 && filtered.length === prev.length) return prev;
return [...toAdd, ...filtered];
});
}, [scopedStoreWorkflows]);
const savedWorkflowsById = useMemo(() => {
return new Map(savedWorkflowItems.map((w) => [w.id, w]));
}, [savedWorkflowItems]);
const isWorkflowDirty = (workflow: Workflow) => {
const saved = savedWorkflowsById.get(workflow.id);
if (!saved) return true;
return (
workflow.name !== saved.name ||
workflow.description !== saved.description ||
(workflow.agent_profile_id ?? "") !== (saved.agent_profile_id ?? "")
);
};
return {
workflowItems,
setWorkflowItems,
savedWorkflowItems,
setSavedWorkflowItems,
isWorkflowDirty,
};
}
function storeItemToWorkflow(sw: {
id: string;
workspaceId: string;
name: string;
description?: string | null;
}): Workflow {
return {
id: workflowId(sw.id),
workspace_id: toWorkspaceId(sw.workspaceId),
name: sw.name,
description: sw.description,
created_at: "",
updated_at: "",
};
}