diff --git a/src/app/org/[githubLogin]/connections/OrgCanvasBackground.tsx b/src/app/org/[githubLogin]/connections/OrgCanvasBackground.tsx index 22089f4136..2884d74f40 100644 --- a/src/app/org/[githubLogin]/connections/OrgCanvasBackground.tsx +++ b/src/app/org/[githubLogin]/connections/OrgCanvasBackground.tsx @@ -5,12 +5,6 @@ import { usePathname, useSearchParams } from "next/navigation"; import { AddNodeButton, SystemCanvas, - addEdge, - addNode, - removeEdge, - removeNode, - updateEdge, - updateNode, type AddNodeButtonRenderProps, type BreadcrumbEntry, type CanvasData, @@ -20,7 +14,6 @@ import { type EdgeUpdate, type NodeContextMenuConfig, type NodeMenuOption, - type NodeUpdate, type SystemCanvasHandle, } from "system-canvas-react"; import type { @@ -35,85 +28,27 @@ import { connectionsTheme } from "./canvas-theme"; import { HiddenLivePill, type HiddenLiveEntry } from "./HiddenLivePill"; import { getOrgChannelName, PUSHER_EVENTS } from "@/lib/pusher"; import { usePusherChannel } from "@/hooks/usePusherChannel"; -import { - InitiativeDialog, - type InitiativeForm, -} from "@/components/initiatives/InitiativeDialog"; -import { - MilestoneDialog, - type MilestoneForm, -} from "@/components/initiatives/MilestoneDialog"; -import { - CreateFeatureCanvasDialog, - type FeatureAssignForm, - type FeatureCreateForm, -} from "../_components/CreateFeatureCanvasDialog"; +import { InitiativeDialog } from "@/components/initiatives/InitiativeDialog"; +import { MilestoneDialog } from "@/components/initiatives/MilestoneDialog"; +import { CreateFeatureCanvasDialog } from "../_components/CreateFeatureCanvasDialog"; import { CreateServiceCanvasDialog } from "../_components/CreateServiceCanvasDialog"; -import type { - InitiativeResponse, - MilestoneResponse, -} from "@/types/initiatives"; import { categoryAllowedOnScope, CATEGORY_REGISTRY } from "./canvas-categories"; import { useCanvasChatStore } from "../_state/canvasChatStore"; -import { useSendCanvasChatMessage } from "../_state/useSendCanvasChatMessage"; import { useSession } from "next-auth/react"; import { useCanvasCollaboration } from "@/hooks/useCanvasCollaboration"; import { toast } from "sonner"; +import { Spinner } from "@/components/ui/spinner"; +import { Button } from "@/components/ui/button"; import { computeNodeFocusZoom } from "@/lib/canvas/nodeZoom"; - -/** - * Live-id detection mirrors `src/lib/canvas/scope.ts`'s `isLiveId`. - * Duplicated here because that module is server-side (pulls Prisma); we - * only need the prefix check on the client. Keep the prefix list in sync - * with `LIVE_ID_PREFIXES` there. - */ -const LIVE_ID_PREFIXES = ["ws:", "feature:", "repo:", "initiative:", "milestone:", "task:", "research:"]; -function isLiveId(id: string): boolean { - return LIVE_ID_PREFIXES.some((p) => id.startsWith(p)); -} - -/** - * Categories that, when picked from the `+` menu, open a creation - * dialog instead of dropping a node onto the canvas. The dialog hits - * the appropriate REST API; on success the projector re-emits the new - * node, and we save the user's click position so the node lands where - * they clicked. - * - * Whether each category is *visible* in the `+` menu on the current - * scope is decided by `categoryAllowedOnScope` in `canvas-categories.ts` - * — see `renderAddNodeButton` below. Both filters consult the same - * helper so a category never shows in the menu but fails the dispatch - * (or vice versa). - */ -const DB_CREATING_CATEGORIES = new Set(["initiative", "milestone", "feature"]); - -/** - * Authored category ids the user can drop onto a live container card - * (workspace / initiative / milestone) to move the authored node from - * the current canvas blob to the target's sub-canvas blob. Stays in - * lock-step with `canvas-categories.ts` — anything authored (not - * `agentWritable: false`) and free-floating belongs here. `text` is - * the library's base type used by the `+ Text` menu pick; including - * it lets users shuffle plain text cards too. - * - * Module-level so the arrays' identity stays stable across renders - * and `useCallback` deps don't churn. - */ -const AUTHORED_DROPPABLE_CATEGORIES = ["note", "decision", "text"]; - - - -/** - * Live container categories that own a sub-canvas an authored node - * can be moved into. Mirrors the `ref: liveId` projections in - * `src/lib/canvas/projectors.ts` — workspaces and initiatives each - * drill. Milestones are intentionally NOT in this list: they render - * as cards on the initiative canvas and are not drillable, so there - * is no sub-canvas to drop authored notes onto. Features also don't - * drill (no `ref` in v1 per the projector); attempting to move an - * authored node onto a feature or milestone would have nowhere to land. - */ -const LIVE_CONTAINER_CATEGORIES = ["workspace", "initiative"]; +import { isLiveId } from "@/lib/canvas"; +import { + useCanvasPersistence, + fetchRoot, + fetchSub, +} from "./useCanvasPersistence"; +import { useCanvasHiddenLive } from "./useCanvasHiddenLive"; +import { useCanvasEdgeOps } from "./useCanvasEdgeOps"; +import { useCanvasNodeOps } from "./useCanvasNodeOps"; /** * Full-screen interactive system-canvas background for the Connections page. @@ -125,8 +60,6 @@ const LIVE_CONTAINER_CATEGORIES = ["workspace", "initiative"]; * and schedule a per-ref debounced save to our REST endpoints. */ -const ROOT_KEY = "__root__"; -const AUTOSAVE_MS = 600; /** * Color applied to edges that have a linked Connection doc, so they @@ -137,94 +70,6 @@ const AUTOSAVE_MS = 600; */ const LINKED_EDGE_COLOR = "#a4b3cc"; -type DirtyMap = Map; - -type LastAction = - | { - kind: "hide"; // user hid a live node → undo = show - canvasRef: string | undefined; - id: string; - } - | { - kind: "show"; // user restored a live node → undo = hide - canvasRef: string | undefined; - id: string; - }; - -async function fetchRoot(githubLogin: string): Promise { - const res = await fetch(`/api/orgs/${githubLogin}/canvas`); - if (!res.ok) throw new Error(`Failed to load canvas: ${res.status}`); - const body = await res.json(); - return (body.data ?? { nodes: [], edges: [] }) as CanvasData; -} - -async function fetchSub(githubLogin: string, ref: string): Promise { - const res = await fetch( - `/api/orgs/${githubLogin}/canvas/${encodeURIComponent(ref)}`, - ); - if (!res.ok) throw new Error(`Failed to load sub-canvas: ${res.status}`); - const body = await res.json(); - return (body.data ?? { nodes: [], edges: [] }) as CanvasData; -} - -async function saveCanvas( - githubLogin: string, - ref: string | undefined, - data: CanvasData, -): Promise { - const url = ref - ? `/api/orgs/${githubLogin}/canvas/${encodeURIComponent(ref)}` - : `/api/orgs/${githubLogin}/canvas`; - const res = await fetch(url, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ data }), - }); - if (!res.ok) { - // Best-effort: surface in console but don't throw — we want autosave - // to keep trying on the next edit rather than get stuck. - console.error(`[OrgCanvasBackground] PUT failed (${res.status})`); - } -} - -/** - * Toggle the visibility of a projected live node. Routed through the - * dedicated `/canvas/hide` endpoint so it can't be clobbered by the - * autosave PUT (which only writes nodes + positions). - */ -async function toggleLiveVisibility( - githubLogin: string, - ref: string | undefined, - id: string, - action: "hide" | "show", -): Promise { - const res = await fetch(`/api/orgs/${githubLogin}/canvas/hide`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ ref: ref ?? "", id, action }), - }); - if (!res.ok) { - console.error( - `[OrgCanvasBackground] ${action} failed for ${id} (${res.status})`, - ); - } -} - -async function fetchHiddenLive( - githubLogin: string, - ref: string | undefined, -): Promise { - const qs = ref ? `?ref=${encodeURIComponent(ref)}` : ""; - const res = await fetch(`/api/orgs/${githubLogin}/canvas/hide${qs}`); - if (!res.ok) { - console.error( - `[OrgCanvasBackground] fetchHiddenLive failed (${res.status})`, - ); - return []; - } - const body = await res.json(); - return (body.entries ?? []) as HiddenLiveEntry[]; -} /** * `CanvasSelection` enriched with the resolved human-readable labels @@ -361,9 +206,19 @@ export function OrgCanvasBackground({ onLinkedConnectionIdsChange, }: OrgCanvasBackgroundProps) { const { data: session } = useSession(); - const [root, setRoot] = useState(null); - const [subCanvases, setSubCanvases] = useState>({}); - const [loadError, setLoadError] = useState(null); + const { + root, + setRoot, + subCanvases, + setSubCanvases, + loadError, + retryLoad, + rootRef, + subCanvasesRef, + dirtyRef, + applyMutation, + onResolveCanvas, + } = useCanvasPersistence({ githubLogin }); /** * Current canvas scope as the user has it open. `""` is root; any * non-empty value is a sub-canvas ref (e.g. `"ws:abc"` or @@ -574,50 +429,26 @@ export function OrgCanvasBackground({ [onSelectionChange], ); - /** - * Hidden live entries for the CURRENT canvas (whatever `currentRef` - * points at). Drives the HiddenLivePill, which surfaces "restore" - * for whatever scope the user is looking at — workspaces on root, - * repos on a workspace sub-canvas, etc. Refetched whenever - * `currentRef` changes (see effect below). - * - * `null` means "not yet fetched on the current scope." We render - * the pill as `entries={hiddenLive ?? []}` so the brief gap during - * a refetch reads as "no entries" rather than flashing a stale list - * from the previous canvas. - */ - const [hiddenLive, setHiddenLive] = useState(null); - /** - * Hidden live entries for the ROOT canvas specifically — independent - * of `currentRef`. The chat workspace seed in `OrgCanvasView` only - * cares about hidden workspaces (which only live on root); piping the - * current-scope list through `onHiddenChange` would re-seed the chat - * with repo/milestone entries when the user drills into a sub-canvas. - * `null` means "not yet fetched"; the notify effect short-circuits - * while null so the parent's first non-stub callback is the real list. - */ - const [rootHiddenLive, setRootHiddenLive] = useState(null); - - // Keep the latest sub-canvases available to the save flusher without - // re-creating the debounce timer every render. - const subCanvasesRef = useRef(subCanvases); - useEffect(() => { - subCanvasesRef.current = subCanvases; - }, [subCanvases]); - const rootRef = useRef(root); - useEffect(() => { - rootRef.current = root; - }, [root]); - // Mirror of `currentRef` for callbacks that need to read it without - // re-binding (Pusher handler, refresh helpers). The Pusher subscription - // effect intentionally only depends on `githubLogin` so we don't tear - // down + resubscribe every navigation; reading through this ref keeps - // the handler pointed at the user's current scope. const currentRefRef = useRef(currentRef); useEffect(() => { currentRefRef.current = currentRef; }, [currentRef]); + const { + hiddenLive, + refreshHiddenLive, + refreshRootHiddenLive, + handleRestoreLive, + } = useCanvasHiddenLive({ + githubLogin, + currentRef, + currentRefRef, + setRoot, + setSubCanvases, + applyMutation, + onHiddenChange, + }); + // Viewport tracking for clipboard paste placement (ref, not state — no re-renders). const currentViewportRef = useRef<{ x: number; y: number; zoom: number }>({ x: 0, @@ -673,83 +504,6 @@ export function OrgCanvasBackground({ [writeCanvasUrlParam, onCanvasBreadcrumbChange], ); - // Map keyed by ROOT_KEY or sub-canvas ref -> latest data waiting to flush. - const dirtyRef = useRef(new Map()); - const flushTimer = useRef | null>(null); - const lastActionRef = useRef(null); - - const scheduleFlush = useCallback(() => { - if (flushTimer.current) clearTimeout(flushTimer.current); - flushTimer.current = setTimeout(() => { - const pending = dirtyRef.current; - dirtyRef.current = new Map(); - flushTimer.current = null; - for (const [key, data] of pending) { - const ref = key === ROOT_KEY ? undefined : key; - void saveCanvas(githubLogin, ref, data); - } - }, AUTOSAVE_MS); - }, [githubLogin]); - - const markDirty = useCallback( - (canvasRef: string | undefined, next: CanvasData) => { - dirtyRef.current.set(canvasRef ?? ROOT_KEY, next); - scheduleFlush(); - }, - [scheduleFlush], - ); - - // Initial root load + hidden-list fetch. Both are root-scoped, both - // run once per org switch; bundling them here keeps the effect count - // low and guarantees the pill shows up on first paint (no flash). - useEffect(() => { - let cancelled = false; - fetchRoot(githubLogin) - .then((data) => { - if (!cancelled) setRoot(data); - }) - .catch((err) => { - if (!cancelled) { - console.error("[OrgCanvasBackground] failed to load root", err); - setLoadError("Failed to load canvas"); - } - }); - // Root hidden list seeds both `rootHiddenLive` (which drives the - // chat workspace context via `onHiddenChange`) and `hiddenLive` - // (the pill's current-scope list — equal to the root list while - // we're still on root). When the user navigates into a sub-canvas - // the per-ref effect below replaces `hiddenLive`; `rootHiddenLive` - // stays put. - fetchHiddenLive(githubLogin, undefined).then((entries) => { - if (cancelled) return; - setRootHiddenLive(entries); - setHiddenLive(entries); - }); - return () => { - cancelled = true; - }; - }, [githubLogin]); - - // Refetch the current canvas's hidden list whenever the user drills - // into / out of a sub-canvas. Skips the root case — the initial-load - // effect above already populates `hiddenLive` for `currentRef === ""`, - // and re-running on every root visit would just duplicate that work. - // The pill auto-hides when entries are empty, so a sub-canvas with - // nothing hidden still costs zero chrome. - useEffect(() => { - if (currentRef === "") return; - let cancelled = false; - // Optimistic clear so the previous canvas's list never flashes - // on the new scope. - setHiddenLive(null); - fetchHiddenLive(githubLogin, currentRef).then((entries) => { - if (!cancelled) setHiddenLive(entries); - }); - return () => { - cancelled = true; - }; - }, [githubLogin, currentRef]); - // Scrolls to and zooms into a specific node by ID. Used for `?node=` // deep-link resolution. Silently swallows rejection (stale link — // node no longer exists on the current canvas). @@ -875,59 +629,6 @@ export function OrgCanvasBackground({ // write-only after that, and `handleBreadcrumbsChange` is the // single source of truth for `currentRef`.) - // Refetch the current canvas's hidden list. Used after a hide on a - // sub-canvas (so the pill picks up the fresh entry) and as the - // reconcile path when an optimistic restore's follow-up read fails. - const refreshHiddenLive = useCallback(() => { - const ref = currentRefRef.current; - fetchHiddenLive(githubLogin, ref === "" ? undefined : ref).then( - setHiddenLive, - ); - }, [githubLogin]); - - // Refetch the ROOT hidden list specifically. Drives `onHiddenChange`, - // which seeds the chat workspace context — that contract is root-only - // (workspaces only exist at root), independent of the user's current - // scope. Used by the Pusher handler when a root-level update arrives. - const refreshRootHiddenLive = useCallback(() => { - fetchHiddenLive(githubLogin, undefined).then((entries) => { - setRootHiddenLive(entries); - // If the user is still on root, keep the pill in sync too. - if (currentRefRef.current === "") setHiddenLive(entries); - }); - }, [githubLogin]); - - // Notify the parent whenever the ROOT hidden-live set changes. Skips - // until the initial fetch resolves (`rootHiddenLive` starts as `null`), - // so the parent's first non-stub callback is the real list. - // Critical for `OrgCanvasView`: it gates the chat mount on - // `hiddenInitialized`, and the chat reads `defaultExtraWorkspaceSlugs` - // only on mount — firing this with an empty stub would seed the - // chat without any hidden filtering, then the real list would land - // too late to take effect. - useEffect(() => { - if (rootHiddenLive === null) return; - onHiddenChange?.(rootHiddenLive); - }, [rootHiddenLive, onHiddenChange]); - - // Flush any pending saves on unmount so we don't lose the last edit. - useEffect(() => { - return () => { - if (flushTimer.current) { - clearTimeout(flushTimer.current); - flushTimer.current = null; - } - const pending = dirtyRef.current; - dirtyRef.current = new Map(); - for (const [key, data] of pending) { - const ref = key === ROOT_KEY ? undefined : key; - // Fire-and-forget; the tab is gone by the time this resolves but - // fetch() still sends the request. - void saveCanvas(githubLogin, ref, data); - } - }; - }, [githubLogin]); - // Listen for agent-driven canvas updates. When the AI agent (or any // other tab / user) rewrites the canvas through `update_canvas` / // `patch_canvas`, the server emits `CANVAS_UPDATED` on the org channel @@ -976,14 +677,7 @@ export function OrgCanvasBackground({ err, ); }); - // Root hidden list can shift (agent hid/unhid a node, another - // tab toggled it). Cheap call — one row read + Set lookup. - // Mirror into `hiddenLive` too when the user is currently on - // root, so the pill stays in sync with `rootHiddenLive`. - fetchHiddenLive(githubLogin, undefined).then((entries) => { - setRootHiddenLive(entries); - if (currentRefRef.current === "") setHiddenLive(entries); - }); + refreshRootHiddenLive(); return; } // Only refetch sub-canvases we've actually opened. The cache is @@ -1004,7 +698,7 @@ export function OrgCanvasBackground({ // at, refresh its hidden list too — agents can hide live nodes // on sub-canvases just like on root. if (ref === currentRefRef.current) { - fetchHiddenLive(githubLogin, ref).then(setHiddenLive); + refreshHiddenLive(); } }; @@ -1016,1978 +710,163 @@ export function OrgCanvasBackground({ }; }, [channel, channelName, githubLogin]); - // Resolve a sub-canvas ref: serve from cache if present, otherwise fetch. - const onResolveCanvas = useCallback( - async (ref: string): Promise => { - const cached = subCanvasesRef.current[ref]; - if (cached) return cached; - const data = await fetchSub(githubLogin, ref); - setSubCanvases((prev) => ({ ...prev, [ref]: data })); - return data; - }, - [githubLogin], - ); - - // ----- Editing helpers ------------------------------------------------- - // - // We route every mutation to either root state or the sub-canvas map, - // keyed by `canvasRef` (undefined = root). The system-canvas core helpers - // do the immutable merge for us, so each handler is essentially: - // - // newData = helper(currentData, args) - // setLocal(newData); markDirty(canvasRef, newData); - // - // The two branches (root vs sub-canvas) are structurally identical; we - // factor them through `applyMutation`. + const { + pendingAdd, + setPendingAdd, + startFeatureCreate, + handleNodeAdd, + handleNodeUpdate, + handleNodesUpdate, + handleNodeDelete, + handleNodesDelete, + handleSaveInitiative, + handleSaveMilestone, + handleSaveFeature, + handleSaveService, + handleAssignFeature, + } = useCanvasNodeOps({ + githubLogin, + currentRefRef, + rootRef, + subCanvasesRef, + applyMutation, + setRoot, + setSubCanvases, + refreshHiddenLive, + refreshRootHiddenLive, + }); - const applyMutation = useCallback( - ( - canvasRef: string | undefined, - mutate: (data: CanvasData) => CanvasData, - ) => { - if (!canvasRef) { - const current = rootRef.current; - if (!current) return; - const next = mutate(current); - setRoot(next); - markDirty(undefined, next); - return; - } - const current = subCanvasesRef.current[canvasRef]; - if (!current) return; - const next = mutate(current); - setSubCanvases((prev) => ({ ...prev, [canvasRef]: next })); - markDirty(canvasRef, next); - }, - [markDirty], - ); + // Real-time canvas presence — cursors, selection halos, conflict flash + const { collaborators } = useCanvasCollaboration({ + githubLogin, + canvasRef: currentRef, + userId: session?.user?.id ?? "", + userName: session?.user?.name ?? "", + userImage: session?.user?.image ?? null, + getViewport: () => canvasHandleRef.current?.getViewport() ?? { x: 0, y: 0, zoom: 1 }, + getSvgElement: () => canvasHandleRef.current?.getSvgElement?.() ?? null, + containerRef: canvasContainerRef, + selectedNodeId: selectedNodeIdForPresence, + enabled: !!(session?.user?.id), + }); - // ------------------------------------------------------------------- - // DB-creating `+` menu interception - // - // When the user picks `Initiative` or `Milestone` from the `+` menu, - // the library hands us a freshly-synthesized authored node. We do - // NOT add it to the canvas blob. Instead we open the matching - // creation dialog with the click position cached, hit the REST API - // on save, then save the click position into `Canvas.data.positions` - // for the projected node id so the new card lands where the user - // clicked. The Pusher `CANVAS_UPDATED` event from the API then - // re-projects the new entity onto the canvas. - // - // Cancel: nothing happens. No node was added. - // ------------------------------------------------------------------- + const { + handleEdgeAdd, + handleEdgeUpdate, + handleEdgeDelete, + canDropNodeOn, + handleNodeDrop, + } = useCanvasEdgeOps({ + githubLogin, + applyMutation, + edgePatchHandleRef, + }); /** - * Pending dialog state for an interception. Carries the click - * position so we can save it once the API returns the new entity id. - * `canvasRef` tracks which canvas the click came from — root for - * initiatives, `initiative:` for milestones. - */ - type PendingInitiativeAdd = { - kind: "initiative"; - x: number; - y: number; - canvasRef: string | undefined; - }; - type PendingMilestoneAdd = { - kind: "milestone"; - x: number; - y: number; - /** Always an `initiative:` ref — milestones can only be added inside one. */ - canvasRef: string; - initiativeId: string; - /** Count + 1 from the server; undefined if fetch failed (dialog opens with empty field). */ - defaultSequence?: number; - }; - /** - * Pending feature-create from either the `+` menu or a "Promote to - * Feature" right-click on a note. `canvasRef` is the scope the user - * triggered from — the dialog uses it to lock fields. `prefill` is - * set on the Promote path; the note's text seeds title + brief. - */ - type PendingFeatureAdd = { - kind: "feature"; - x: number; - y: number; - /** Empty string ("") means root canvas; otherwise the sub-canvas ref. */ - canvasRef: string | undefined; - prefill?: { title?: string; brief?: string }; - /** - * If set, the dialog was opened by promoting a note. After save we - * delete the source note from its canvas (the user "consumed" it). - */ - sourceNoteId?: string; - }; - /** - * Pending service-create from the `+ Service` menu pick on a workspace - * sub-canvas. Unlike initiative / milestone / feature, services are - * NOT DB-projected — the new node lands directly in the canvas blob - * via `applyMutation` once the dialog returns. We carry only the click - * position and the source canvas ref through; the dialog supplies - * the name + platform kind. + * Visually highlight edges that have a linked Connection doc. The + * lib renders edges using `theme.edge.stroke` by default; setting + * `edge.color` overrides that with a per-edge value. We map linked + * edges to a brighter slate so they stand out at a glance — small + * affordance, big discoverability win (the user can see at a + * glance which edges have docs behind them). * - * `node` is the freshly-synthesized authored node the library handed - * us (carrying the lib-generated id, default size from the category, - * and the click x/y). We hold onto it so we can drop the dialog's - * name + kind into it on save without re-synthesizing the id. + * Decoration is render-only: it lands on the data passed to + * `` but NOT on the persisted blob. `applyMutation` + * reads from `rootRef.current` / `subCanvasesRef.current` (the + * undecorated state mirrors), so writes never leak the visual + * `color` back into the canvas. Memoized on the source canvas so + * the lib's edge-render pass stays pure. */ - type PendingServiceAdd = { - kind: "service"; - node: CanvasNode; - canvasRef: string | undefined; - }; - type PendingAdd = - | PendingInitiativeAdd - | PendingMilestoneAdd - | PendingFeatureAdd - | PendingServiceAdd; + const decorateEdgesWithLinkVisual = useCallback( + (data: CanvasData): CanvasData => { + const edges = data.edges ?? []; + let changed = false; + const next = edges.map((e) => { + const cd = (e as { customData?: { connectionId?: unknown } }) + .customData; + const linked = + typeof cd?.connectionId === "string" && cd.connectionId.length > 0; + // Don't override an explicit user-set color — if the edge + // already has one, the user (or agent) made a deliberate + // visual choice we shouldn't clobber. + if (linked && !e.color) { + changed = true; + return { ...e, color: LINKED_EDGE_COLOR }; + } + return e; + }); + return changed ? { ...data, edges: next } : data; + }, + [], + ); - const [pendingAdd, setPendingAdd] = useState(null); + const canvasForRender = useMemo( + () => decorateEdgesWithLinkVisual(root ?? { nodes: [], edges: [] }), + [root, decorateEdgesWithLinkVisual], + ); - /** - * Save a freshly-created live node's click position into the canvas - * blob so it lands where the user clicked. Fire-and-forget: the API - * response from POST already returned the new id, so we can write - * the position before the projector re-emits the node — by the time - * Pusher fires the refresh, the position overlay is already in place. - */ - const savePositionForLiveId = useCallback( - async ( - canvasRef: string | undefined, - liveId: string, - x: number, - y: number, - ) => { - try { - // Read-modify-write the relevant canvas. We read first so we - // don't clobber other position overlays the user already set. - const url = canvasRef - ? `/api/orgs/${githubLogin}/canvas/${encodeURIComponent(canvasRef)}` - : `/api/orgs/${githubLogin}/canvas`; - const res = await fetch(url); - if (!res.ok) return; - const body = await res.json(); - const data: CanvasData = body.data ?? { nodes: [], edges: [] }; - // Append a stub node carrying the position. The server's - // splitter will treat its live id as a positions overlay and - // discard everything else (text/category/customData). It - // doesn't matter that the projector hasn't emitted the real - // node yet — the position survives independently. - const existingNodes = data.nodes ?? []; - const nextNodes: CanvasNode[] = [ - ...existingNodes.filter((n) => n.id !== liveId), - { - id: liveId, - type: "text", - category: "", - text: "", - x, - y, - }, - ]; - await fetch(url, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ data: { ...data, nodes: nextNodes } }), - }); - } catch (err) { - // Non-fatal: the node will appear at the projector's default - // position and the user can drag it. - console.error( - "[OrgCanvasBackground] savePositionForLiveId failed", - err, - ); + // Sub-canvases also need decoration so links are highlighted on + // every scope (root, workspace sub-canvas, initiative sub-canvas). + // Memoized as a fresh object only when an underlying ref changes. + const subCanvasesForRender = useMemo>(() => { + const out: Record = {}; + for (const [ref, data] of Object.entries(subCanvases)) { + out[ref] = decorateEdgesWithLinkVisual(data); + } + return out; + }, [subCanvases, decorateEdgesWithLinkVisual]); + + // Set of connection ids referenced by at least one edge across all + // canvases we've loaded this session. Walked off the same `root` + + // `subCanvases` state the renderer already uses, so it stays in + // sync automatically (link/unlink → state update → fresh set). + // The sidebar uses this to mark connections that are wired up. + const linkedConnectionIds = useMemo>(() => { + const ids = new Set(); + const collect = (data: CanvasData | null | undefined) => { + for (const e of data?.edges ?? []) { + const cd = (e as { customData?: { connectionId?: unknown } }) + .customData; + if (typeof cd?.connectionId === "string" && cd.connectionId.length > 0) { + ids.add(cd.connectionId); + } } - }, - [githubLogin], - ); + }; + collect(root); + for (const data of Object.values(subCanvases)) collect(data); + return ids; + }, [root, subCanvases]); - // =================================================================== - // Research kickoff - // =================================================================== - // - // The Research feature is the first canvas node type with an - // **authored\u2192live** lifecycle. The user picks `+ Research` from the - // menu, types a topic into the dropped node, and on text commit the - // node fires a synthetic chat message that drives the agent's - // `save_research` tool (see `src/lib/ai/researchTools.ts`). - // - // **The authored\u2192live swap is NOT a client-side concern.** Earlier - // versions of this feature tried to swap in the client (FIFO - // queue + processed-id set + `applyMutation(removeNode)` + various - // race-y refetch sequences). It never converged \u2014 autosave, pusher - // refetch, and the position-overlay write all fought each other, - // and any failure left a phantom authored node in the canvas blob. - // - // The swap now happens **at the IO boundary** in - // `src/lib/canvas/io.ts` (`dedupeAuthoredResearch`): on every read - // and every write, an authored `research` node whose text matches - // a live `research:` node's text is dropped, with its position - // carried into the live node's overlay if no overlay exists. That - // gives us the visible swap on the user's next canvas refresh - // (which Pusher triggers within ~300ms of `save_research` - // returning) and self-heals the persisted blob on the next - // autosave. No client-side queue, no race conditions, no phantom - // nodes. - // - // The only client-side work is firing the kickoff. Tracking which - // authored node ids we've already kicked off prevents a double-fire - // if the user hits Enter twice on the same node before the swap - // happens. + useEffect(() => { + onLinkedConnectionIdsChange?.(linkedConnectionIds); + }, [linkedConnectionIds, onLinkedConnectionIdsChange]); - /** - * Authored research node ids whose kickoff we've already fired. - * The library prefills new nodes with placeholder text ("New - * node") rather than an empty string, so we can't use - * "empty\u2192non-empty" as the trigger. Instead: fire the kickoff the - * first time the user-typed text differs from whatever was there - * before AND we haven't already fired for this id. - */ - const firedResearchKickoffsRef = useRef>(new Set()); + // Clear the viewport slot in the canvas chat store on unmount so stale + // coordinates from a previous canvas session never leak into the next. + useEffect(() => { + return () => { + useCanvasChatStore.getState().setCanvasViewport(null); + }; + }, []); - const sendCanvasChatMessage = useSendCanvasChatMessage(); + // Anchor the canvas container to the viewport but leave `rightInset` + // pixels of empty space on the right so the library-owned FAB + future + // toolbar affordances sit to the LEFT of the sidebar. The background + // color still paints the full inset area (via a separate div) so the + // sidebar doesn't reveal the page background underneath. + const canvasContainerStyle: React.CSSProperties = { + right: rightInset, + }; /** - * Fire the synthetic user message that drives the agent. - * - * The store's `activeConversationId` may legitimately be null for a - * brief window during initial mount (before `OrgCanvasView` calls - * `startConversation`). In that case we drop the kickoff silently - * \u2014 the user can hit Enter again once the chat is ready, or just - * type the request directly. We don't want to retry-loop the - * kickoff because that'd race with the user editing the node. - */ - const fireResearchKickoff = useCallback( - (topic: string) => { - const trimmedTopic = topic.trim(); - if (!trimmedTopic) return; - const conversationId = - useCanvasChatStore.getState().activeConversationId; - if (!conversationId) { - console.warn( - "[OrgCanvasBackground] research kickoff fired before chat conversation was ready; user will need to retry or type the request manually", - ); - return; - } - // Format that the prompt suffix instructs the agent to recognize - // ("if you see a synthetic user message of the form 'Research: - // ', that's the signal"). The agent extracts the topic - // and is told to pass it as `topic` verbatim into save_research, - // which is what makes the IO-layer text-equality dedupe work. - void sendCanvasChatMessage({ - conversationId, - content: `Research: ${trimmedTopic}`, - }); - }, - [sendCanvasChatMessage], - ); - - /** - * Open the InitiativeDialog with the click position cached. Caller - * passes the synthetic node from the library so we can pull `x`/`y` - * off it. - */ - const startInitiativeCreate = useCallback( - (node: CanvasNode, canvasRef: string | undefined) => { - setPendingAdd({ - kind: "initiative", - x: node.x, - y: node.y, - canvasRef, - }); - }, - [], - ); - - /** - * Open the MilestoneDialog. Pre-fetch the initiative's existing - * milestones so we know which sequence numbers are taken (needed - * for the dialog's client-side validation) and what the next-free - * sequence is to pre-populate the form. - * - * Caller (handleNodeAdd) must already have validated the scope — - * this function trusts that `canvasRef` is `initiative:`. - */ - const startMilestoneCreate = useCallback( - async (node: CanvasNode, canvasRef: string) => { - const initiativeId = canvasRef.slice("initiative:".length); - try { - const res = await fetch( - `/api/orgs/${githubLogin}/initiatives/${initiativeId}/milestones`, - ); - const data = res.ok ? await res.json() : null; - const defaultSequence = data ? data.count + 1 : undefined; - setPendingAdd({ - kind: "milestone", - x: node.x, - y: node.y, - canvasRef, - initiativeId, - defaultSequence, - }); - } catch (err) { - console.error( - "[OrgCanvasBackground] failed to fetch milestones for dialog seed", - err, - ); - setPendingAdd({ - kind: "milestone", - x: node.x, - y: node.y, - canvasRef, - initiativeId, - defaultSequence: undefined, - }); - } - }, - [githubLogin], - ); - - /** - * Open the CreateFeatureCanvasDialog. Unlike initiative/milestone - * dialogs, feature creation has no scope-bound pre-fetch: the dialog - * itself loads workspaces / initiatives / edge hints lazily on open. - * The caller passes `prefill` only when this is a promote-from-note - * path (so the dialog seeds its title + description). - */ - const startFeatureCreate = useCallback( - ( - node: CanvasNode, - canvasRef: string | undefined, - prefill?: { title?: string; brief?: string }, - sourceNoteId?: string, - ) => { - setPendingAdd({ - kind: "feature", - x: node.x, - y: node.y, - canvasRef, - prefill, - sourceNoteId, - }); - }, - [], - ); - - /** - * Open the CreateServiceCanvasDialog. Service is authored-only (no - * DB row), so this just stashes the lib-synthesized node and waits - * for the dialog to fill in name + platform `kind` — at which point - * `handleSaveService` writes the node to the canvas blob via - * `applyMutation`. Caller (handleNodeAdd) must have validated scope. - */ - const startServiceCreate = useCallback( - (node: CanvasNode, canvasRef: string | undefined) => { - setPendingAdd({ kind: "service", node, canvasRef }); - }, - [], - ); - - const handleNodeAdd = useCallback( - (node: CanvasNode, canvasRef: string | undefined) => { - const category = node.category ?? ""; - const ref = canvasRef ?? ""; - - // DB-creating categories are routed through their dialog. The - // scope-aware `+` menu filter shouldn't have offered the option - // outside its valid scope, but check here too — a stale menu - // pick (e.g. user navigated mid-click) shouldn't create at the - // wrong scope. Single rule, two enforcement points. - if (DB_CREATING_CATEGORIES.has(category)) { - if (!categoryAllowedOnScope(category, ref)) return; - if (category === "initiative") { - startInitiativeCreate(node, canvasRef); - return; - } - if (category === "milestone") { - // categoryAllowedOnScope guarantees ref starts with "initiative:". - void startMilestoneCreate(node, ref); - return; - } - if (category === "feature") { - // Allowed on every scope per `categoryAllowedOnScope`. The - // dialog handles per-scope field locking; we just hand off - // the click position. - startFeatureCreate(node, canvasRef); - return; - } - } - - // Service is authored-only (no DB row, no projector) but still - // routes through a dialog so the user picks a platform icon up - // front. We don't add it to DB_CREATING_CATEGORIES — that set's - // semantic is "needs a REST POST"; service skips the API entirely - // and lands in the canvas blob via `applyMutation` once the dialog - // returns. Still scope-guarded against a stale menu pick. - if (category === "service") { - if (!categoryAllowedOnScope(category, ref)) return; - startServiceCreate(node, canvasRef); - return; - } - - applyMutation(canvasRef, (c) => addNode(c, node)); - }, - [ - applyMutation, - startInitiativeCreate, - startMilestoneCreate, - startFeatureCreate, - startServiceCreate, - ], - ); - - // ------------------------------------------------------------------- - // Dialog save handlers - // ------------------------------------------------------------------- - - const handleSaveInitiative = useCallback( - async (form: InitiativeForm): Promise => { - if (pendingAdd?.kind !== "initiative") return; - const body: Record = { - name: form.name, - description: form.description || undefined, - status: form.status, - startDate: form.startDate || undefined, - targetDate: form.targetDate || undefined, - completedAt: form.completedAt || undefined, - }; - const res = await fetch(`/api/orgs/${githubLogin}/initiatives`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), - }); - if (!res.ok) { - // Surface enough info to console; the dialog's `onSave` swallows - // the throw so this is the user-visible signal we have today. - // A toast belongs here when we add the global toast system. - console.error( - "[OrgCanvasBackground] create initiative failed", - res.status, - ); - return; - } - const created: InitiativeResponse = await res.json(); - // Pin the new initiative to the click position before refetching - // so the refetched canvas already has the position overlay - // applied. If the position write fails, the projector falls back - // to default placement and the user can drag. - await savePositionForLiveId( - pendingAdd.canvasRef, - `initiative:${created.id}`, - pendingAdd.x, - pendingAdd.y, - ); - // Local refetch: don't wait for the Pusher fan-out (300ms delay - // + WebSocket round-trip + dirtyRef guard). The user just took - // an explicit action; the new card should appear immediately. - // Pusher still handles other tabs / users. - try { - const data = await fetchRoot(githubLogin); - setRoot(data); - } catch (err) { - console.error( - "[OrgCanvasBackground] refetch after create initiative failed", - err, - ); - } - }, - [githubLogin, pendingAdd, savePositionForLiveId], - ); - - const handleSaveMilestone = useCallback( - async (form: MilestoneForm): Promise<{ error?: string }> => { - if (pendingAdd?.kind !== "milestone") return {}; - const body: Record = { - name: form.name, - description: form.description || undefined, - status: form.status, - sequence: parseInt(form.sequence, 10), - dueDate: form.dueDate || undefined, - completedAt: form.completedAt || undefined, - }; - const res = await fetch( - `/api/orgs/${githubLogin}/initiatives/${pendingAdd.initiativeId}/milestones`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), - }, - ); - if (res.status === 409) { - return { - error: - "A milestone with that sequence already exists in this initiative.", - }; - } - if (!res.ok) { - return { error: "Failed to create milestone." }; - } - const created: MilestoneResponse = await res.json(); - // Same pattern as initiative create: position-save first so the - // refetch already reflects it, then refresh the timeline locally - // for snappy UX. Pusher still notifies other tabs. - const subRef = pendingAdd.canvasRef; - await savePositionForLiveId( - subRef, - `milestone:${created.id}`, - pendingAdd.x, - pendingAdd.y, - ); - try { - const data = await fetchSub(githubLogin, subRef); - setSubCanvases((prev) => ({ ...prev, [subRef]: data })); - } catch (err) { - console.error( - "[OrgCanvasBackground] refetch after create milestone failed", - err, - ); - } - return {}; - }, - [githubLogin, pendingAdd, savePositionForLiveId], - ); - - /** - * Save handler for the CreateFeatureCanvasDialog. Hits POST - * /api/features with the resolved `(workspaceId, initiativeId, - * milestoneId)` triple, then routes the click-position save and - * canvas refetch to the **target** canvas — which is the most - * specific scope the new feature lives on, NOT the canvas the user - * triggered from. Example: user creates from root and picks an - * initiative → the new feature card appears on the initiative's - * sub-canvas, so we save the position there and refetch that - * sub-canvas (not root). - * - * If the dialog was opened via "Promote to Feature" on a note, - * `pendingAdd.sourceNoteId` is set and we remove that note from the - * originating canvas after the feature is created — the user - * "consumed" the note when they promoted it. - */ - const handleSaveFeature = useCallback( - async (form: FeatureCreateForm): Promise => { - if (pendingAdd?.kind !== "feature") return; - const body: Record = { - title: form.title, - workspaceId: form.workspaceId, - ...(form.brief ? { brief: form.brief } : {}), - ...(form.initiativeId ? { initiativeId: form.initiativeId } : {}), - ...(form.milestoneId ? { milestoneId: form.milestoneId } : {}), - }; - const res = await fetch(`/api/features`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), - }); - if (!res.ok) { - // Surface enough info to console; toast lands here when the - // global toast system arrives (same gap as initiative/milestone). - const detail = await res.text().catch(() => ""); - console.error( - "[OrgCanvasBackground] create feature failed", - res.status, - detail, - ); - return; - } - const payload = await res.json(); - const created: { id: string } = payload?.data ?? payload; - - // Resolve the target canvas ref by the "most specific place - // wins" rule. This must mirror the projector emission rules: - // - initiativeId set (with or without milestoneId) → initiative: - // - neither set → ws: - // Milestone-bound features render on their parent initiative's - // canvas (with a synthetic edge to the milestone card on the - // same canvas) — there is no separate milestone canvas. Mismatch - // here would save the position overlay on the wrong canvas, the - // projected node would render at the projector default, and the - // user's click position would silently vanish. - const targetRef: string = form.initiativeId - ? `initiative:${form.initiativeId}` - : `ws:${form.workspaceId}`; - - // Save the click position before refetching so the position - // overlay is already in the blob by the time the canvas reads. - // Note we pass the **target** ref, not the source canvas ref — - // the position lives on the canvas the feature renders on. - await savePositionForLiveId( - targetRef, - `feature:${created.id}`, - pendingAdd.x, - pendingAdd.y, - ); - - // Promote-from-note path: remove the source note. We do this - // through the same `applyMutation` flow as a regular delete so - // the autosave + Pusher broadcast follow the standard path. - if (pendingAdd.sourceNoteId) { - applyMutation(pendingAdd.canvasRef, (c) => - removeNode(c, pendingAdd.sourceNoteId!), - ); - } - - // Local refetch of the **target** canvas so the new feature - // shows up immediately. If the target is the same canvas the - // user is on, this is the natural refresh; if it's elsewhere - // (e.g. created on root, lands on initiative), the user will - // see the card on first drill-in. Pusher fan-out handles other - // tabs/users. - try { - if (targetRef === "") { - // Defensive: today no feature lands on root (the matrix - // above always picks a sub-canvas), but keep this branch - // so the code stays correct if the rules expand. - const data = await fetchRoot(githubLogin); - setRoot(data); - } else { - const data = await fetchSub(githubLogin, targetRef); - setSubCanvases((prev) => ({ ...prev, [targetRef]: data })); - } - } catch (err) { - console.error( - "[OrgCanvasBackground] refetch after create feature failed", - err, - ); - } - }, - [githubLogin, pendingAdd, savePositionForLiveId, applyMutation], - ); - - /** - * Save handler for `CreateServiceCanvasDialog`. Pure canvas-blob write - * — no REST POST, no projector. We take the lib-synthesized node we - * stashed in `pendingAdd.node`, merge in the dialog's `name` (as - * `text`) and `kind` (as `customData.kind`), and drop the result into - * the canvas blob via `applyMutation`. The click position the lib - * gave us is already on `node.x` / `node.y` so the card lands where - * the user clicked. - * - * No projector means no Pusher fan-out — the local `applyMutation` - * is the only state update needed. Other tabs/users see the new - * service on their next canvas read (or via autosave's mirror to - * `Canvas.data`). - */ - const handleSaveService = useCallback( - async (form: { name: string; kind: string }): Promise => { - if (pendingAdd?.kind !== "service") return; - const { node, canvasRef } = pendingAdd; - const merged: CanvasNode = { - ...node, - text: form.name, - customData: { - // Preserve anything the lib synthesized (today there's - // nothing, but defensive merge so future lib fields survive). - ...(node.customData ?? {}), - kind: form.kind, - }, - }; - applyMutation(canvasRef, (c) => addNode(c, merged)); - }, - [pendingAdd, applyMutation], - ); - - /** - * Save handler for the CreateFeatureCanvasDialog's **Assign existing** - * tab. Two flavors driven by the discriminated payload: - * - * - `kind: "workspace-pin"` (workspace canvas) — POST to - * `/canvas/assigned-features` with `action: "assign"` to add the - * feature id to the canvas's `assignedFeatures` overlay. Feature - * row unchanged. Click position lands the card. - * - * - `kind: "initiative-attach"` (initiative canvas) — PATCH - * `/api/features/[id]` with `{ initiativeId }` to set the loose - * feature's anchor. Service-side `notifyFeatureReassignmentRefresh` - * fans out CANVAS_UPDATED to both initiatives (in this case just - * the landing one — `before.initiativeId` is null for loose - * features). The projector emits the card on this initiative's - * canvas alongside its milestones on the next read. - * - * Both paths converge on the same `savePositionForLiveId + refetch` - * tail so the card lands where the user clicked. - */ - const handleAssignFeature = useCallback( - async (form: FeatureAssignForm): Promise => { - if (pendingAdd?.kind !== "feature") return; - - // Resolve `(targetRef, mutationFetch)` per flavor. `targetRef` - // is always the canvas the user is currently on — exactly the - // canvas the new card should appear on, since both flavors - // re-project onto the source scope after the mutation. - let targetRef: string; - let mutationFetch: () => Promise; - - if (form.kind === "workspace-pin") { - targetRef = `ws:${form.workspaceId}`; - // Defensive: the dialog only surfaces the workspace-pin - // payload when the pending scope is the matching workspace - // canvas. Bail out instead of silently writing to the wrong - // canvas if it doesn't. - if (pendingAdd.canvasRef !== targetRef) { - console.error( - "[OrgCanvasBackground] handleAssignFeature canvasRef mismatch (workspace)", - { pendingRef: pendingAdd.canvasRef, targetRef }, - ); - return; - } - mutationFetch = () => - fetch(`/api/orgs/${githubLogin}/canvas/assigned-features`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - ref: targetRef, - featureId: form.featureId, - action: "assign", - }), - }); - } else { - targetRef = `initiative:${form.initiativeId}`; - if (pendingAdd.canvasRef !== targetRef) { - console.error( - "[OrgCanvasBackground] handleAssignFeature canvasRef mismatch (initiative)", - { pendingRef: pendingAdd.canvasRef, targetRef }, - ); - return; - } - mutationFetch = () => - fetch(`/api/features/${form.featureId}`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ initiativeId: form.initiativeId }), - }); - } - - const res = await mutationFetch(); - if (!res.ok) { - const detail = await res.text().catch(() => ""); - console.error( - "[OrgCanvasBackground] assign feature failed", - form.kind, - res.status, - detail, - ); - return; - } - - await savePositionForLiveId( - targetRef, - `feature:${form.featureId}`, - pendingAdd.x, - pendingAdd.y, - ); - try { - const data = await fetchSub(githubLogin, targetRef); - setSubCanvases((prev) => ({ ...prev, [targetRef]: data })); - } catch (err) { - console.error( - "[OrgCanvasBackground] refetch after assign feature failed", - err, - ); - } - }, - [githubLogin, pendingAdd, savePositionForLiveId], - ); - - /** - * Persist a milestone status change to the DB. Fire-and-forget; on - * success the API route emits CANVAS_UPDATED which round-trips through - * the projector and reconciles the canonical status. On failure, log - * and let the next read pull the unchanged status — the optimistic - * local change will be quietly reverted, which is acceptable for the - * "I clicked the wrong swatch" scenario. - */ - const persistMilestoneStatus = useCallback( - async ( - milestoneId: string, - initiativeId: string, - status: "NOT_STARTED" | "IN_PROGRESS" | "COMPLETED", - ) => { - try { - const res = await fetch( - `/api/orgs/${githubLogin}/initiatives/${initiativeId}/milestones/${milestoneId}`, - { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status }), - }, - ); - if (!res.ok) { - console.error( - `[OrgCanvasBackground] PATCH milestone status failed (${res.status})`, - ); - } - } catch (err) { - console.error( - "[OrgCanvasBackground] PATCH milestone status threw", - err, - ); - } - }, - [githubLogin], - ); - - const persistMilestoneName = useCallback( - async (milestoneId: string, initiativeId: string, name: string) => { - try { - const res = await fetch( - `/api/orgs/${githubLogin}/initiatives/${initiativeId}/milestones/${milestoneId}`, - { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ name }), - }, - ); - if (!res.ok) { - const detail = await res.text().catch(() => ""); - console.error( - "[OrgCanvasBackground] PATCH milestone name failed", - res.status, - detail, - ); - } - } catch (err) { - console.error( - "[OrgCanvasBackground] PATCH milestone name threw", - err, - ); - } - }, - [githubLogin], - ); - - /** - * Persist a feature title rename to the DB. Same fire-and-forget - * shape as `persistMilestoneStatus`: optimistic local update lands - * via `applyMutation`, then the PATCH round-trips through the API - * route → projector → Pusher CANVAS_UPDATED, which reconciles to - * the canonical `Feature.title`. On failure, log and let the next - * read snap the card back to the prior title. - * - * Trim happens server-side too (`updateFeature`), but we trim here - * to skip the network call when the user typed only whitespace. - */ - const persistFeatureTitle = useCallback( - async (featureId: string, title: string) => { - const trimmed = title.trim(); - if (trimmed.length === 0) return; - try { - const res = await fetch(`/api/features/${featureId}`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ title: trimmed }), - }); - if (!res.ok) { - const detail = await res.text().catch(() => ""); - console.error( - "[OrgCanvasBackground] PATCH feature title failed", - res.status, - detail, - ); - } - } catch (err) { - console.error("[OrgCanvasBackground] PATCH feature title threw", err); - } - }, - [], - ); - - const handleNodeUpdate = useCallback( - (id: string, patch: NodeUpdate, canvasRef: string | undefined) => { - // Snapshot the pre-update node BEFORE we apply the mutation. The - // research-kickoff branch below needs to see whether the text - // just transitioned empty\u2192non-empty, which requires the prior - // value. `applyMutation` reads from refs that lag React state - // by a render anyway, so reading them here is consistent with - // what the mutation will see. - const sourceCanvas = canvasRef - ? subCanvasesRef.current[canvasRef] - : rootRef.current; - const prevNode = sourceCanvas?.nodes?.find((n) => n.id === id); - - // Optimistic local update first — keeps the swatch reflecting the - // user's choice immediately, regardless of which path we take - // below. The autosave will silently drop customData on live ids - // (the splitter discards it), so this never round-trips through - // the canvas blob — the PATCH below is the only persistence path - // for status. - applyMutation(canvasRef, (c) => updateNode(c, id, patch)); - - // For milestone live nodes: a status change is a DB mutation, not - // a canvas blob change. Intercept it here and PATCH the milestone - // REST endpoint. Position changes (x/y) still flow through the - // normal autosave path as `positions[liveId]` overlays. - if ( - id.startsWith("milestone:") && - canvasRef?.startsWith("initiative:") - ) { - const newStatus = patch.customData?.status; - if ( - newStatus === "NOT_STARTED" || - newStatus === "IN_PROGRESS" || - newStatus === "COMPLETED" - ) { - const milestoneId = id.slice("milestone:".length); - const initiativeId = canvasRef.slice("initiative:".length); - void persistMilestoneStatus(milestoneId, initiativeId, newStatus); - } - } - - // For feature live nodes: a text edit is a DB title rename, not - // a canvas blob change. The splitter drops `text` on live ids - // (see `src/lib/canvas/io.ts`), so without this intercept the - // user's edit reverts on the next read. Skip when the text - // didn't actually change to avoid spurious PATCHes from - // re-renders. - if (id.startsWith("feature:") && patch.text !== undefined) { - const prevText = (prevNode?.text ?? "").trim(); - const nextText = patch.text.trim(); - if (nextText.length > 0 && nextText !== prevText) { - const featureId = id.slice("feature:".length); - void persistFeatureTitle(featureId, nextText); - } - } - - // For milestone live nodes: a text edit is a DB name rename, not - // a canvas blob change. The splitter drops `text` on live ids - // (see `src/lib/canvas/io.ts`), so without this intercept the - // user's edit reverts on the next read. Skip when the text - // didn't actually change to avoid spurious PATCHes. - if (id.startsWith("milestone:") && patch.text !== undefined) { - const prevText = (prevNode?.text ?? "").trim(); - const nextText = patch.text.trim(); - if ( - nextText.length > 0 && - nextText !== prevText && - canvasRef?.startsWith("initiative:") - ) { - const milestoneId = id.slice("milestone:".length); - const initiativeId = canvasRef.slice("initiative:".length); - void persistMilestoneName(milestoneId, initiativeId, nextText); - } - } - - // Research kickoff trigger. When an authored research node's - // text gets edited for the first time, fire the chat-side - // kickoff that drives `save_research`. Authored = no - // `research:` id prefix; that prefix only appears once the - // projector emits the live node post-save. The authored - // placeholder gets dropped automatically once the live node - // arrives \u2014 the dedupe runs in `dedupeAuthoredResearch` - // (`src/lib/canvas/io.ts`) on every read AND every write. - // - // The trigger isn't "empty\u2192non-empty" because the library - // prefills new nodes with placeholder text like "New node" - // instead of "". Instead, we fire on the FIRST text edit per - // authored node id (tracked in `firedResearchKickoffsRef`), - // skipping spurious updates where text didn't actually change. - if ( - prevNode && - prevNode.category === "research" && - !id.startsWith("research:") && - patch.text !== undefined && - !firedResearchKickoffsRef.current.has(id) - ) { - const prevText = (prevNode.text ?? "").trim(); - const nextText = patch.text.trim(); - if (nextText.length > 0 && nextText !== prevText) { - firedResearchKickoffsRef.current.add(id); - fireResearchKickoff(nextText); - } - } - }, - [ - applyMutation, - fireResearchKickoff, - persistMilestoneStatus, - persistMilestoneName, - persistFeatureTitle, - ], - ); - - /** - * Batched drag commit. Fires once per drag-end with every moved node - * — for a single-node drag that's a one-entry array; for a group - * drag it's the group plus every spatially-contained child. - * - * Why this exists: `applyMutation` reads `rootRef.current` / - * `subCanvasesRef.current`, both mirrored from React state via - * `useEffect`. The mirror runs *after* React commits, so a synchronous - * loop of per-node `onNodeUpdate` calls all read the same stale - * starting state and each `setRoot` overwrites the previous one. The - * visible bug: dragging a group leaves every child snapping back - * except the last one in the iteration order. Folding all moves - * into a single `applyMutation` (which chains every patch through - * `updateNode` against one starting state) commits the group - * atomically. - * - * The drag path never carries `customData` — only x/y — so we can - * skip the milestone status special case here. Toolbar status - * changes still come through `onNodeUpdate` as before. - */ - const handleNodesUpdate = useCallback( - ( - updates: { id: string; patch: NodeUpdate }[], - canvasRef: string | undefined, - ) => { - if (updates.length === 0) return; - applyMutation(canvasRef, (c) => - updates.reduce((acc, u) => updateNode(acc, u.id, u.patch), c), - ); - }, - [applyMutation], - ); - const handleNodeDelete = useCallback( - (id: string, canvasRef: string | undefined) => { - // Live nodes (workspaces, repos, features) aren't deletable — they - // belong to the DB. "Delete" on the canvas means "hide on this - // view." Route to the dedicated endpoint, and optimistically drop - // the node from local state so the UI reacts immediately. The - // endpoint is idempotent, so a race with a concurrent hide is - // harmless. - if (isLiveId(id)) { - lastActionRef.current = { kind: "hide", canvasRef, id }; - applyMutation(canvasRef, (c) => removeNode(c, id)); - void toggleLiveVisibility(githubLogin, canvasRef, id, "hide").then( - () => { - // Refresh whichever hidden list this hide affected so the - // pill picks up the new entry. Hides on root also feed the - // chat workspace seed (`rootHiddenLive`). - if (!canvasRef) { - refreshRootHiddenLive(); - } else if (canvasRef === currentRefRef.current) { - refreshHiddenLive(); - } - }, - ); - return; - } - applyMutation(canvasRef, (c) => removeNode(c, id)); - }, - [applyMutation, githubLogin, refreshHiddenLive, refreshRootHiddenLive], - ); - - /** - * Restore a hidden live node on the canvas the user is currently - * looking at. Fire-and-forget the server call, then refetch that - * canvas so the newly-unhidden node gets its full projection (name, - * ref, rollups) rather than the stub we have in `hiddenLive`. - * Works for any scope: workspaces on root, repos on a workspace - * sub-canvas, etc. — `currentRef` decides which canvas the - * `/canvas/hide` endpoint mutates. - */ - const handleRestoreLive = useCallback( - async (id: string) => { - const ref = currentRefRef.current; - const isRoot = ref === ""; - lastActionRef.current = { - kind: "show", - canvasRef: isRoot ? undefined : ref, - id, - }; - // Optimistic: remove from the pill immediately so users don't - // wait on a round-trip before the popover reflects their click. - // The pill is only ever shown after the initial fetch resolved - // (it's mounted by `entries={hiddenLive ?? []}`), so this path - // can only fire while `prev` is a real array — but guard anyway - // so a misbehaving caller can't crash here. - setHiddenLive((prev) => - prev === null ? prev : prev.filter((e) => e.id !== id), - ); - // Mirror the optimistic removal into `rootHiddenLive` too when - // we're on root so the chat seed stays in sync without a refetch. - if (isRoot) { - setRootHiddenLive((prev) => - prev === null ? prev : prev.filter((e) => e.id !== id), - ); - } - await toggleLiveVisibility( - githubLogin, - isRoot ? undefined : ref, - id, - "show", - ); - // Refetch the canvas the restore happened on so the newly-visible - // projected node renders. Hidden-list refetch is implicit — we - // already removed it locally. - try { - if (isRoot) { - const data = await fetchRoot(githubLogin); - setRoot(data); - } else { - const data = await fetchSub(githubLogin, ref); - setSubCanvases((prev) => ({ ...prev, [ref]: data })); - } - } catch (err) { - console.error("[OrgCanvasBackground] refetch after restore failed", err); - // Reconcile the pill if the refetch dropped out — the server's - // current state is the source of truth. - refreshHiddenLive(); - if (isRoot) refreshRootHiddenLive(); - } - }, - [githubLogin, refreshHiddenLive, refreshRootHiddenLive], - ); - - const handleUndo = useCallback(async () => { - const action = lastActionRef.current; - if (!action) return; - lastActionRef.current = null; // consume — ctrl-z twice is a no-op - - if (action.kind === "hide") { - // Undo a hide → reuse the existing handleRestoreLive path exactly - await handleRestoreLive(action.id); - return; - } - - if (action.kind === "show") { - // Undo a restore → re-hide (mirror of handleNodeDelete live path) - const ref = action.canvasRef; - applyMutation(ref, (c) => removeNode(c, action.id)); - await toggleLiveVisibility(githubLogin, ref, action.id, "hide"); - if (!ref) { - refreshRootHiddenLive(); - } else if (ref === currentRefRef.current) { - refreshHiddenLive(); - } - } - }, [ - markDirty, - applyMutation, - githubLogin, - handleRestoreLive, - refreshHiddenLive, - refreshRootHiddenLive, - ]); - - // Ctrl-Z / Cmd-Z undo listener — scoped to canvas mount lifetime. - useEffect(() => { - const onKeyDown = (e: KeyboardEvent) => { - if (!e.ctrlKey && !e.metaKey) return; - if (e.key !== "z") return; - // Don't intercept undo inside text inputs / rich-text editors - const tag = (e.target as HTMLElement).tagName; - if ( - tag === "INPUT" || - tag === "TEXTAREA" || - (e.target as HTMLElement).isContentEditable - ) - return; - e.preventDefault(); - void handleUndo(); - }; - document.addEventListener("keydown", onKeyDown); - return () => document.removeEventListener("keydown", onKeyDown); - }, [handleUndo]); - - // Real-time canvas presence — cursors, selection halos, conflict flash - const { collaborators } = useCanvasCollaboration({ - githubLogin, - canvasRef: currentRef, - userId: session?.user?.id ?? "", - userName: session?.user?.name ?? "", - userImage: session?.user?.image ?? null, - getViewport: () => canvasHandleRef.current?.getViewport() ?? { x: 0, y: 0, zoom: 1 }, - getSvgElement: () => canvasHandleRef.current?.getSvgElement?.() ?? null, - containerRef: canvasContainerRef, - selectedNodeId: selectedNodeIdForPresence, - enabled: !!(session?.user?.id), - }); - - /** - * Detect a user-drawn edge whose endpoints are a feature card and a - * milestone card on the initiative canvas. Either direction is - * accepted (feature → milestone or milestone → feature). Returns - * `{ featureId, milestoneId }` for the membership PATCH or `null` - * when the edge is something else (feature-to-feature dependency, - * authored note → live container, etc. — those flow through the - * default blob path). - */ - const detectFeatureMilestoneEdge = useCallback( - ( - edge: CanvasEdge, - ): { featureId: string; milestoneId: string } | null => { - const a = edge.fromNode; - const b = edge.toNode; - if (a.startsWith("feature:") && b.startsWith("milestone:")) { - return { - featureId: a.slice("feature:".length), - milestoneId: b.slice("milestone:".length), - }; - } - if (a.startsWith("milestone:") && b.startsWith("feature:")) { - return { - featureId: b.slice("feature:".length), - milestoneId: a.slice("milestone:".length), - }; - } - return null; - }, - [], - ); - - /** - * Fire-and-forget PATCH that attaches a feature to a milestone via - * `Feature.milestoneId`. Same shape as the drag-drop reassignment - * path (`reassignFeatureToMilestone`), used here so user-drawn - * edges express DB membership instead of authored-blob edges. - * Pass `null` to detach. The Pusher fan-out from the API route - * handles the canvas refresh; the synthetic edge appears (or - * disappears) on the next read. - */ - const patchFeatureMilestone = useCallback( - async (featureId: string, milestoneId: string | null) => { - try { - const res = await fetch(`/api/features/${featureId}`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ milestoneId }), - }); - if (!res.ok) { - const detail = await res.text().catch(() => ""); - console.error( - "[OrgCanvasBackground] patchFeatureMilestone failed", - res.status, - detail, - ); - } - } catch (err) { - console.error("[OrgCanvasBackground] patchFeatureMilestone threw", err); - } - }, - [], - ); - - /** - * Detect a user-drawn edge whose endpoints are two feature cards on - * the initiative canvas. The direction matters: the source is the - * BLOCKER (the dependency, the thing that must finish first), the - * target is the BLOCKED (the dependent feature whose - * `dependsOnFeatureIds` array will gain the blocker's id). Returns - * `null` for any other shape — milestone↔feature edges are caught - * by `detectFeatureMilestoneEdge` above; the milestone check runs - * first in `handleEdgeAdd`. - */ - const detectFeatureBlocksEdge = useCallback( - ( - edge: CanvasEdge, - ): { blockerId: string; blockedId: string } | null => { - const a = edge.fromNode; - const b = edge.toNode; - if (a.startsWith("feature:") && b.startsWith("feature:")) { - return { - blockerId: a.slice("feature:".length), - blockedId: b.slice("feature:".length), - }; - } - return null; - }, - [], - ); - - /** - * Fire-and-forget PATCH that adds or removes a blocker on a - * feature's `dependsOnFeatureIds` array. Read-modify-write because - * the column is a flat string array; the service-layer - * `updateFeature` runs validation + the cycle check before writing. - * Pass `mode: "add"` to append (no-op if already present) or - * `"remove"` to filter out. The Pusher fan-out (reused - * `notifyFeatureContentRefresh`) handles the canvas refresh. - */ - const patchFeatureBlocks = useCallback( - async ( - blockedId: string, - blockerId: string, - mode: "add" | "remove", - ) => { - try { - // Read current array first — `dependsOnFeatureIds` is a flat - // column and the API expects the full replacement set. - const readRes = await fetch(`/api/features/${blockedId}`); - if (!readRes.ok) { - console.error( - "[OrgCanvasBackground] patchFeatureBlocks read failed", - readRes.status, - ); - return; - } - const readBody = await readRes.json(); - const current: string[] = - (readBody?.data?.dependsOnFeatureIds as string[] | undefined) ?? []; - const next = - mode === "add" - ? Array.from(new Set([...current, blockerId])) - : current.filter((id) => id !== blockerId); - // Skip no-op writes — saves a round trip and avoids - // emitting CANVAS_UPDATED for a write that didn't change - // anything. - if ( - next.length === current.length && - next.every((id, i) => id === current[i]) - ) { - return; - } - const res = await fetch(`/api/features/${blockedId}`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ dependsOnFeatureIds: next }), - }); - if (!res.ok) { - const detail = await res.text().catch(() => ""); - console.error( - "[OrgCanvasBackground] patchFeatureBlocks failed", - res.status, - detail, - ); - } - } catch (err) { - console.error("[OrgCanvasBackground] patchFeatureBlocks threw", err); - } - }, - [], - ); - - const handleEdgeAdd = useCallback( - (edge: CanvasEdge, canvasRef: string | undefined) => { - // Intercept feature↔milestone edges as DB membership writes - // rather than authored-blob edges. The relationship is owned by - // `Feature.milestoneId`; the projector emits a synthetic edge - // on every read to visually represent it. Letting the - // user-drawn edge sit in the blob alongside the synthetic one - // would create two parallel representations that can disagree - // when the DB is mutated through other paths. - // - // To avoid the user seeing their edge "disappear and reappear" - // across the PATCH + Pusher round-trip, we **rename** the - // edge's id in place to the predicted synthetic id rather than - // removing it. Two consequences fall out for free: - // 1. The autosave splitter (`splitCanvas`) filters out - // `synthetic:` ids, so this optimistic edge is never - // persisted into the blob — DB membership stays the only - // source of truth. - // 2. When the Pusher-driven refetch lands ~hundreds of ms - // later carrying the real projector-emitted synthetic - // edge, it has the *same* id and endpoints, so React's - // diff is a no-op — the user sees a continuous edge - // throughout. - // The id format must match what `milestoneTimelineProjector` - // emits: `synthetic:feature-milestone:`. Drift here - // would silently double-render edges across the round-trip. - const link = detectFeatureMilestoneEdge(edge); - if (link) { - const syntheticId = `synthetic:feature-milestone:${link.featureId}`; - // Canonicalize the optimistic edge to match exactly what - // `milestoneTimelineProjector` will emit on the next refetch - // — same id, same endpoint order (feature→milestone), and - // crucially **no `fromSide`/`toSide`** so the library's - // auto-router routes both versions identically. With matching - // shape, the Pusher-driven swap is a React diff no-op and - // the user sees a continuous edge throughout the round-trip. - // Synthetic edges are DB-derived layout, not user-authored; - // we intentionally don't preserve which handle the user - // happened to click — trust the auto-router. - applyMutation(canvasRef, (c) => { - const withoutTemp = removeEdge(c, edge.id); - // Skip if the projector-emitted edge is already in the - // canvas (e.g. user re-drew an edge that already exists). - // Adding a duplicate id would crash the library. - if (withoutTemp.edges?.some((e) => e.id === syntheticId)) { - return withoutTemp; - } - return addEdge(withoutTemp, { - id: syntheticId, - fromNode: `feature:${link.featureId}`, - toNode: `milestone:${link.milestoneId}`, - } as CanvasEdge); - }); - void patchFeatureMilestone(link.featureId, link.milestoneId); - return; - } - - // Feature→feature dependency edge (the "blocks" relation). Same - // pattern as the milestone interception: swap the user-drawn - // edge's id for the predicted synthetic id, PATCH the DB, let - // the Pusher-driven refetch confirm via the projector. The - // server-side cycle check in `updateFeature` rejects the write - // if it would create a cycle — the optimistic edge then sits - // briefly until the next refetch removes it (the projector - // won't re-emit a non-persisted dependency). - const blocksLink = detectFeatureBlocksEdge(edge); - if (blocksLink && blocksLink.blockerId !== blocksLink.blockedId) { - const syntheticId = - `synthetic:feature-blocks:${blocksLink.blockerId}:${blocksLink.blockedId}`; - applyMutation(canvasRef, (c) => { - const withoutTemp = removeEdge(c, edge.id); - if (withoutTemp.edges?.some((e) => e.id === syntheticId)) { - return withoutTemp; - } - return addEdge(withoutTemp, { - id: syntheticId, - fromNode: `feature:${blocksLink.blockerId}`, - toNode: `feature:${blocksLink.blockedId}`, - customData: { kind: "blocks" }, - } as CanvasEdge); - }); - void patchFeatureBlocks( - blocksLink.blockedId, - blocksLink.blockerId, - "add", - ); - return; - } - - applyMutation(canvasRef, (c) => addEdge(c, edge)); - }, - [ - applyMutation, - detectFeatureMilestoneEdge, - patchFeatureMilestone, - detectFeatureBlocksEdge, - patchFeatureBlocks, - ], - ); - const handleEdgeUpdate = useCallback( - (id: string, patch: EdgeUpdate, canvasRef: string | undefined) => { - // Synthetic edges (DB-projected feature→milestone membership) are - // not in the blob, so library `updateEdge` calls against them - // would no-op silently. The most common "update" on a synthetic - // edge would be repointing one endpoint — that's a membership - // change, which belongs as a PATCH. Defensive guard: ignore - // patches addressed at synthetic ids. - if (id.startsWith("synthetic:")) return; - applyMutation(canvasRef, (c) => updateEdge(c, id, patch)); - }, - [applyMutation], - ); - const handleEdgeDelete = useCallback( - (id: string, canvasRef: string | undefined) => { - // Deleting a synthetic feature→milestone membership edge means - // "this feature is no longer in this milestone" — a DB write, - // not a blob delete. PATCH `milestoneId: null`; the projector - // re-runs and the synthetic edge disappears. - // - // We ALSO optimistically remove it from local state so the - // user sees the edge disappear immediately rather than - // lingering until the Pusher refetch lands. `splitCanvas` - // filters `synthetic:` ids so this removal doesn't produce an - // extraneous authored-edge delete in the persisted blob. - if (id.startsWith("synthetic:feature-milestone:")) { - const featureId = id.slice("synthetic:feature-milestone:".length); - applyMutation(canvasRef, (c) => removeEdge(c, id)); - void patchFeatureMilestone(featureId, null); - return; - } - // Deleting a synthetic dependency edge means "this feature no - // longer depends on that one." Same posture as the milestone - // case: optimistic local remove, then PATCH the - // `dependsOnFeatureIds` array minus the blocker. Id format: - // `synthetic:feature-blocks::`. - if (id.startsWith("synthetic:feature-blocks:")) { - const ids = id.slice("synthetic:feature-blocks:".length).split(":"); - if (ids.length === 2) { - const [blockerId, blockedId] = ids; - applyMutation(canvasRef, (c) => removeEdge(c, id)); - void patchFeatureBlocks(blockedId, blockerId, "remove"); - return; - } - } - applyMutation(canvasRef, (c) => removeEdge(c, id)); - }, - [applyMutation, patchFeatureMilestone, patchFeatureBlocks], - ); - - // Expose the edge-patch path through the parent-supplied ref so - // sibling surfaces (the Connections-tab link mode) can write - // `customData.connectionId` without prop-drilling a callback - // through every list row. Re-wire on every render so the latest - // closure is always exposed (the ref's identity is stable, the - // function it points at is not — that's the desired semantics). - useEffect(() => { - if (!edgePatchHandleRef) return; - edgePatchHandleRef.current = handleEdgeUpdate; - return () => { - // Null out on unmount so a stale handler can't fire after the - // canvas tears down. - if (edgePatchHandleRef.current === handleEdgeUpdate) { - edgePatchHandleRef.current = null; - } - }; - }, [edgePatchHandleRef, handleEdgeUpdate]); - - // ------------------------------------------------------------------- - // Drop-on-node — three pairings today: - // - // 1. **Feature → Milestone (DB reassign).** Drag a `feature:` card - // onto a `milestone:` card to PATCH `Feature.milestoneId`. The - // server derives `initiativeId` and fans out CANVAS_UPDATED on - // every affected canvas; the projector re-emits the feature on - // the most-specific scope. - // - // 2. **Authored callout → Live container (canvas move).** Drag a - // `note` / `decision` / base-`text` node onto a `workspace:` / - // `initiative:` / `milestone:` card to MOVE the authored node - // from its current canvas blob onto the target's sub-canvas - // blob. Lets the user accumulate loose thoughts on the root - // canvas, then organize them under the workspace / initiative / - // milestone they belong to without retyping. The note's text + - // category + customData survive the move (it's a blob-to-blob - // hop, not a DB write). - // - // 3. **Research → Initiative (DB reassign).** Drag a `research:` - // card onto an `initiative:` card to PATCH - // `Research.initiativeId`. The row jumps from the root canvas - // to the initiative sub-canvas (or between two initiative - // sub-canvases) on the next projector run. Drop coords are - // intentionally NOT preserved — the source and target live on - // different canvases, so the source's `(x, y)` has no meaning - // on the destination; the projector's default initiative-canvas - // slot is the right landing spot. Symmetric "unscope to root" - // isn't supported via drop today (root has no container card - // to drop onto); a future right-click menu or "drop on empty - // canvas" gesture can cover it. - // - // Library-side hooks: `canDropNodeOn` is the per-frame predicate - // during drag (must be cheap — id-prefix sniff + category check, no - // fetches, no setState); `onNodeDrop` is the release handler. The - // library snaps the source back to its pre-drag position before - // firing `onNodeDrop`, so we never commit a canvas-position update. - // - // Self-drop is filtered library-side, but we keep an explicit - // `source.id !== target.id` line for defensive readability. - // - // The lookup arrays (`AUTHORED_DROPPABLE_CATEGORIES`, - // `LIVE_CONTAINER_CATEGORIES`) live at module scope above so - // `useCallback` deps stay stable. - // ------------------------------------------------------------------- - const canDropNodeOn = useCallback( - (sources: CanvasNode[], target: CanvasNode): boolean => { - // Accept if at least one source forms a valid pairing with the - // target. This enables multi-select drops where only some of the - // dragged nodes are droppable onto the target. - return sources.some((source) => { - if (!source || source.id === target.id) return false; - - // Pairing 1: feature → milestone (DB reassign). - if ( - source.category === "feature" && - target.category === "milestone" && - source.id.startsWith("feature:") && - target.id.startsWith("milestone:") - ) { - return true; - } - - // Pairing 2: authored callout → live container (canvas move). - // The source must be authored (not a live id) and the target - // must be a container with a sub-canvas to land in. - if ( - AUTHORED_DROPPABLE_CATEGORIES.includes(source.category ?? "") && - !isLiveId(source.id) && - LIVE_CONTAINER_CATEGORIES.includes(target.category ?? "") && - isLiveId(target.id) - ) { - return true; - } - - // Pairing 3: research → initiative (DB reassign). Mirrors - // pairing 1 structurally — the gesture targets a DB column, - // not the canvas blob, so the synthetic split-between-canvases - // happens via the projector after the fan-out. - if ( - source.category === "research" && - target.category === "initiative" && - source.id.startsWith("research:") && - target.id.startsWith("initiative:") - ) { - return true; - } - - return false; - }); - }, - [], - ); - - /** - * Fire-and-forget PATCH that reassigns a feature to a different - * milestone. The server derives the new `initiativeId` from the - * milestone (via the coherence rule we shipped in `updateFeature`) - * and fans out `CANVAS_UPDATED` on every affected canvas (root, - * both initiatives, both milestones, workspace). We don't refetch - * locally — the Pusher fan-out handler in this component already - * pulls fresh data for whichever canvas the user is currently - * looking at, plus root. - * - * On error we surface to the console; the user's drop visually - * snapped back (the library handled that), so there's no stuck - * "ghost card" to clean up. A toast belongs here when we add the - * global toast system. - */ - const reassignFeatureToMilestone = useCallback( - async (featureId: string, milestoneId: string) => { - try { - const res = await fetch(`/api/features/${featureId}`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ milestoneId }), - }); - if (!res.ok) { - const detail = await res.text().catch(() => ""); - console.error( - "[OrgCanvasBackground] reassign feature failed", - res.status, - detail, - ); - } - } catch (err) { - console.error( - "[OrgCanvasBackground] reassign feature threw", - err, - ); - } - }, - [], - ); - - /** - * Fire-and-forget PATCH that reassigns a research row to a different - * initiative (or to root, with `initiativeId: null`). Same posture - * as `reassignFeatureToMilestone`: no local refetch — the server - * fans out `CANVAS_UPDATED` on both source and target refs via - * `notifyResearchReassignmentRefresh`, and this component's Pusher - * handler picks up the relevant canvas. - * - * On error we surface to the console; the library snapped the drop - * back, so there's no stuck "ghost card" to clean up. A toast - * belongs here when the global toast system lands. - */ - const reassignResearchToInitiative = useCallback( - async (researchId: string, initiativeId: string | null) => { - try { - const res = await fetch( - `/api/orgs/${githubLogin}/research/${researchId}`, - { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ initiativeId }), - }, - ); - if (!res.ok) { - const detail = await res.text().catch(() => ""); - console.error( - "[OrgCanvasBackground] reassign research failed", - res.status, - detail, - ); - } - } catch (err) { - console.error( - "[OrgCanvasBackground] reassign research threw", - err, - ); - } - }, - [githubLogin], - ); - - /** - * Move one or more authored nodes from the source canvas blob to the - * target's sub-canvas blob. Two side-effects: - * - * 1. **Remove** the nodes from `currentRef` (the canvas the user is - * looking at) in a single `applyMutation` so it's optimistic, - * undoable via one Ctrl-Z (the whole multi-move reverses - * atomically), and rides the standard autosave flush that - * already covers in-flight blob writes. - * - * 2. **Add** the nodes to the target's sub-canvas via a single - * direct read-modify-write PUT. The target canvas may not be - * cached locally (the user might never have drilled into it), so - * we use the same pattern as `savePositionForLiveId`: fetch - * the latest blob, append the whole batch, PUT it back. The PUT - * emits `CANVAS_UPDATED` server-side; both canvases (the one - * we removed from + the one we added to) refetch via the - * existing Pusher handler, so the user sees the move land on - * the target if they drill in. - * - * The batch matters: a fetch+PUT per node races against itself — - * every PUT reads the same starting blob, appends only its own node, - * and the last write clobbers the rest, so only one node would land. - * One read-modify-write covering the whole batch avoids that. - * - * Each node's `id` / `text` / `category` / `customData` survives the - * move verbatim. Position is preserved as-is from the source — the - * user can drag them once they drill into the target. Nodes dropped - * from similar source positions may overlap in the target; resetting - * `(x, y)` to a projector-default would be marginally nicer but means - * computing each target's empty-canvas anchor; not worth the - * complexity for v1. - * - * On a server-side write failure, the source-canvas remove already - * landed locally. The autosave will eventually flush that removal, - * leaving the user with "lost notes" if the target write also - * failed. Acceptable for v1 (Ctrl-Z undoes the source removal); a - * proper rollback would need the consumer-side undo stack to track - * cross-canvas paired actions, which is overkill here. - */ - const moveAuthoredNodesToCanvas = useCallback( - async ( - sourceCanvasRef: string | undefined, - sourceNodes: CanvasNode[], - targetCanvasRef: string, - ) => { - if (sourceNodes.length === 0) return; - - // Step 1: optimistic remove of every moved node from the source - // canvas in a SINGLE mutation. `applyMutation` also stamps - // `lastActionRef` so Ctrl-Z restores the nodes to their pre-move - // positions on the source canvas. One mutation keeps the undo - // entry atomic (the whole multi-move reverses in one Ctrl-Z). - const movedIds = new Set(sourceNodes.map((n) => n.id)); - applyMutation(sourceCanvasRef, (c) => { - let next = c; - for (const id of movedIds) next = removeNode(next, id); - return next; - }); - - // Step 2: ONE read-modify-write that appends ALL moved nodes to - // the target canvas. Doing a fetch+PUT per node races: each PUT - // reads the same starting blob, appends only its own node, and - // the last write clobbers the rest — so only one node lands. We - // append the whole batch in a single PUT instead. Direct fetch - // (not `applyMutation`) because the target canvas may not be in - // `subCanvases` yet — the user could be on root and dropping onto - // an initiative they've never drilled into. - try { - const url = `/api/orgs/${githubLogin}/canvas/${encodeURIComponent(targetCanvasRef)}`; - const res = await fetch(url); - if (!res.ok) { - console.error( - "[OrgCanvasBackground] moveAuthoredNodesToCanvas read failed", - res.status, - ); - return; - } - const body = await res.json(); - const data: CanvasData = body.data ?? { nodes: [], edges: [] }; - const existingNodes = data.nodes ?? []; - // De-dupe by id in case the user did rapid double-drops or a - // Pusher refresh interleaved — a moved id should never appear - // twice on a single canvas. Drop any existing copies of the - // moved ids, then append the fresh batch. - const nextNodes: CanvasNode[] = [ - ...existingNodes.filter((n) => !movedIds.has(n.id)), - ...sourceNodes, - ]; - const putRes = await fetch(url, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ data: { ...data, nodes: nextNodes } }), - }); - if (!putRes.ok) { - console.error( - "[OrgCanvasBackground] moveAuthoredNodesToCanvas write failed", - putRes.status, - ); - } - } catch (err) { - console.error( - "[OrgCanvasBackground] moveAuthoredNodesToCanvas threw", - err, - ); - } - }, - [applyMutation, githubLogin], - ); - - const handleNodeDrop = useCallback( - ( - sources: CanvasNode[], - target: CanvasNode, - ctx: { canvasRef: string | undefined }, - ) => { - // Classify every dropped source by pairing. The DB-reassign - // pairings (1 and 3) hit independent per-id endpoints, so they're - // safe to fire one PATCH per source. The canvas-move pairing (2) - // writes a SINGLE shared blob, so we must collect all of its - // sources and issue one batched read-modify-write — firing a - // fetch+PUT per node races (each PUT clobbers the others, leaving - // only one node in the target). Sources with no matching pairing - // are silently skipped — this handles mixed multi-selections where - // only some nodes are droppable onto the target. - const authoredToMove: CanvasNode[] = []; - - for (const source of sources) { - if (!source) continue; - - // Pairing 1: feature → milestone (DB reassign). The predicate - // ran during drag, but a mid-drag agent edit could have changed - // the categories — re-check defensively, then derive the DB - // ids from the canvas-id strings. - if ( - source.category === "feature" && - target.category === "milestone" && - source.id.startsWith("feature:") && - target.id.startsWith("milestone:") - ) { - const featureId = source.id.slice("feature:".length); - const milestoneId = target.id.slice("milestone:".length); - void reassignFeatureToMilestone(featureId, milestoneId); - continue; - } - - // Pairing 2: authored callout → live container (canvas move). - // The target's `ref` is the sub-canvas to move the authored - // node into. Defensive: if the projector ever stops emitting a - // `ref` on a container we'd silently no-op, which is the right - // failure mode (better than writing to an unknown scope). - // Collect here; the actual move is batched below. - if ( - AUTHORED_DROPPABLE_CATEGORIES.includes(source.category ?? "") && - !isLiveId(source.id) && - LIVE_CONTAINER_CATEGORIES.includes(target.category ?? "") && - target.ref - ) { - authoredToMove.push(source); - continue; - } - - // Pairing 3: research → initiative (DB reassign). Defensive - // re-check of the predicate since a mid-drag agent edit could - // have flipped categories between drag-start and release. - if ( - source.category === "research" && - target.category === "initiative" && - source.id.startsWith("research:") && - target.id.startsWith("initiative:") - ) { - const researchId = source.id.slice("research:".length); - const initiativeId = target.id.slice("initiative:".length); - void reassignResearchToInitiative(researchId, initiativeId); - continue; - } - - // No matching pairing — skip this source. - } - - // Batched canvas-move: one remove + one append covering every - // authored node dropped onto this container. `target.ref` is - // guaranteed present because it's part of the pairing-2 predicate. - if (authoredToMove.length > 0 && target.ref) { - void moveAuthoredNodesToCanvas(ctx.canvasRef, authoredToMove, target.ref); - } - }, - [ - reassignFeatureToMilestone, - moveAuthoredNodesToCanvas, - reassignResearchToInitiative, - ], - ); - - /** - * Visually highlight edges that have a linked Connection doc. The - * lib renders edges using `theme.edge.stroke` by default; setting - * `edge.color` overrides that with a per-edge value. We map linked - * edges to a brighter slate so they stand out at a glance — small - * affordance, big discoverability win (the user can see at a - * glance which edges have docs behind them). - * - * Decoration is render-only: it lands on the data passed to - * `` but NOT on the persisted blob. `applyMutation` - * reads from `rootRef.current` / `subCanvasesRef.current` (the - * undecorated state mirrors), so writes never leak the visual - * `color` back into the canvas. Memoized on the source canvas so - * the lib's edge-render pass stays pure. - */ - const decorateEdgesWithLinkVisual = useCallback( - (data: CanvasData): CanvasData => { - const edges = data.edges ?? []; - let changed = false; - const next = edges.map((e) => { - const cd = (e as { customData?: { connectionId?: unknown } }) - .customData; - const linked = - typeof cd?.connectionId === "string" && cd.connectionId.length > 0; - // Don't override an explicit user-set color — if the edge - // already has one, the user (or agent) made a deliberate - // visual choice we shouldn't clobber. - if (linked && !e.color) { - changed = true; - return { ...e, color: LINKED_EDGE_COLOR }; - } - return e; - }); - return changed ? { ...data, edges: next } : data; - }, - [], - ); - - const canvasForRender = useMemo( - () => decorateEdgesWithLinkVisual(root ?? { nodes: [], edges: [] }), - [root, decorateEdgesWithLinkVisual], - ); - - // Sub-canvases also need decoration so links are highlighted on - // every scope (root, workspace sub-canvas, initiative sub-canvas). - // Memoized as a fresh object only when an underlying ref changes. - const subCanvasesForRender = useMemo>(() => { - const out: Record = {}; - for (const [ref, data] of Object.entries(subCanvases)) { - out[ref] = decorateEdgesWithLinkVisual(data); - } - return out; - }, [subCanvases, decorateEdgesWithLinkVisual]); - - // Set of connection ids referenced by at least one edge across all - // canvases we've loaded this session. Walked off the same `root` + - // `subCanvases` state the renderer already uses, so it stays in - // sync automatically (link/unlink → state update → fresh set). - // The sidebar uses this to mark connections that are wired up. - const linkedConnectionIds = useMemo>(() => { - const ids = new Set(); - const collect = (data: CanvasData | null | undefined) => { - for (const e of data?.edges ?? []) { - const cd = (e as { customData?: { connectionId?: unknown } }) - .customData; - if (typeof cd?.connectionId === "string" && cd.connectionId.length > 0) { - ids.add(cd.connectionId); - } - } - }; - collect(root); - for (const data of Object.values(subCanvases)) collect(data); - return ids; - }, [root, subCanvases]); - - useEffect(() => { - onLinkedConnectionIdsChange?.(linkedConnectionIds); - }, [linkedConnectionIds, onLinkedConnectionIdsChange]); - - // Clear the viewport slot in the canvas chat store on unmount so stale - // coordinates from a previous canvas session never leak into the next. - useEffect(() => { - return () => { - useCanvasChatStore.getState().setCanvasViewport(null); - }; - }, []); - - // Anchor the canvas container to the viewport but leave `rightInset` - // pixels of empty space on the right so the library-owned FAB + future - // toolbar affordances sit to the LEFT of the sidebar. The background - // color still paints the full inset area (via a separate div) so the - // sidebar doesn't reveal the page background underneath. - const canvasContainerStyle: React.CSSProperties = { - right: rightInset, - }; - - /** - * Right-click menu on canvas nodes. Currently surfaces one item: - * **"Promote to Feature…"** on note nodes. Selecting it opens the - * `CreateFeatureCanvasDialog` with the note's `text` pre-filled - * (truncated for the title, full text as the description) and the - * source note id stashed on `pendingAdd` so we delete the note - * after the feature is created — the user "consumed" the note - * when they promoted it. Allowed on every scope; the dialog handles - * per-scope field locking. + * Right-click menu on canvas nodes. Currently surfaces one item: + * **"Promote to Feature…"** on note nodes. Selecting it opens the + * `CreateFeatureCanvasDialog` with the note's `text` pre-filled + * (truncated for the title, full text as the description) and the + * source note id stashed on `pendingAdd` so we delete the note + * after the feature is created — the user "consumed" the note + * when they promoted it. Allowed on every scope; the dialog handles + * per-scope field locking. * * `useMemo` keeps the config object reference stable across renders; * `` reads it on every render to filter items per node, @@ -3275,13 +1154,22 @@ export function OrgCanvasBackground({ }; if (loadError) { - // Fail quiet: the canvas is a background; if it can't load, fall back - // to a plain dark surface rather than blocking the page. - return
; + return ( +
+

Failed to load canvas

+ +
+ ); } if (!root) { - return
; + return ( +
+ +
+ ); } return ( @@ -3304,6 +1192,7 @@ export function OrgCanvasBackground({ onNodeUpdate={handleNodeUpdate} onNodesUpdate={handleNodesUpdate} onNodeDelete={handleNodeDelete} + onNodesDelete={handleNodesDelete} onEdgeAdd={handleEdgeAdd} onEdgeUpdate={handleEdgeUpdate} onEdgeDelete={handleEdgeDelete} diff --git a/src/app/org/[githubLogin]/connections/useCanvasEdgeOps.ts b/src/app/org/[githubLogin]/connections/useCanvasEdgeOps.ts new file mode 100644 index 0000000000..4ca99adb78 --- /dev/null +++ b/src/app/org/[githubLogin]/connections/useCanvasEdgeOps.ts @@ -0,0 +1,716 @@ +"use client"; + +import { useCallback, useEffect } from "react"; +import { + addEdge, + removeEdge, + removeNode, + updateEdge, + type CanvasEdge, + type CanvasNode, + type CanvasData, + type EdgeUpdate, +} from "system-canvas-react"; +import { toast } from "sonner"; +import { isLiveId } from "@/lib/canvas"; + +/** + * Authored-node categories that can be drag-dropped onto a live + * container card (workspace / initiative) to move them between canvas + * blobs. `"text"` is the library's base type used by the `+ Text` + * menu pick; including it lets users shuffle plain text cards too. + * + * Module-level so the arrays' identity stays stable across renders + * and `useCallback` deps don't churn. + */ +export const AUTHORED_DROPPABLE_CATEGORIES = ["note", "decision", "text"]; + +/** + * Live container categories that own a sub-canvas an authored node + * can be moved into. Mirrors the `ref: liveId` projections in + * `src/lib/canvas/projectors.ts` — workspaces and initiatives each + * drill. Milestones are intentionally NOT in this list: they render + * as cards on the initiative canvas and are not drillable, so there + * is no sub-canvas to drop authored notes onto. Features also don't + * drill (no `ref` in v1 per the projector); attempting to move an + * authored node onto a feature or milestone would have nowhere to land. + */ +export const LIVE_CONTAINER_CATEGORIES = ["workspace", "initiative"]; + +interface UseCanvasEdgeOpsOptions { + githubLogin: string; + applyMutation: (canvasRef: string | undefined, mutate: (data: CanvasData) => CanvasData) => void; + edgePatchHandleRef?: React.MutableRefObject<((id: string, patch: EdgeUpdate, canvasRef: string | undefined) => void) | null>; +} + +interface UseCanvasEdgeOpsReturn { + handleEdgeAdd: (edge: CanvasEdge, canvasRef: string | undefined) => void; + handleEdgeUpdate: (id: string, patch: EdgeUpdate, canvasRef: string | undefined) => void; + handleEdgeDelete: (id: string, canvasRef: string | undefined) => void; + canDropNodeOn: (sources: CanvasNode[], target: CanvasNode) => boolean; + handleNodeDrop: (sources: CanvasNode[], target: CanvasNode, ctx: { canvasRef: string | undefined }) => void; +} + +export function useCanvasEdgeOps({ + githubLogin, + applyMutation, + edgePatchHandleRef, +}: UseCanvasEdgeOpsOptions): UseCanvasEdgeOpsReturn { + /** + * Detect a user-drawn edge whose endpoints are a feature card and a + * milestone card on the initiative canvas. Either direction is + * accepted (feature → milestone or milestone → feature). Returns + * `{ featureId, milestoneId }` for the membership PATCH or `null` + * when the edge is something else (feature-to-feature dependency, + * authored note → live container, etc. — those flow through the + * default blob path). + */ + const detectFeatureMilestoneEdge = useCallback( + ( + edge: CanvasEdge, + ): { featureId: string; milestoneId: string } | null => { + const a = edge.fromNode; + const b = edge.toNode; + if (a.startsWith("feature:") && b.startsWith("milestone:")) { + return { + featureId: a.slice("feature:".length), + milestoneId: b.slice("milestone:".length), + }; + } + if (a.startsWith("milestone:") && b.startsWith("feature:")) { + return { + featureId: b.slice("feature:".length), + milestoneId: a.slice("milestone:".length), + }; + } + return null; + }, + [], + ); + + /** + * Fire-and-forget PATCH that attaches a feature to a milestone via + * `Feature.milestoneId`. Same shape as the drag-drop reassignment + * path (`reassignFeatureToMilestone`), used here so user-drawn + * edges express DB membership instead of authored-blob edges. + * Pass `null` to detach. The Pusher fan-out from the API route + * handles the canvas refresh; the synthetic edge appears (or + * disappears) on the next read. + */ + const patchFeatureMilestone = useCallback( + async (featureId: string, milestoneId: string | null) => { + try { + const res = await fetch(`/api/features/${featureId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ milestoneId }), + }); + if (!res.ok) { + const detail = await res.text().catch(() => ""); + console.error( + "[OrgCanvasBackground] patchFeatureMilestone failed", + res.status, + detail, + ); + toast.error("Failed to assign feature to milestone"); + } + } catch (err) { + console.error("[OrgCanvasBackground] patchFeatureMilestone threw", err); + toast.error("Failed to assign feature to milestone"); + } + }, + [], + ); + + /** + * Detect a user-drawn edge whose endpoints are two feature cards on + * the initiative canvas. The direction matters: the source is the + * BLOCKER (the dependency, the thing that must finish first), the + * target is the BLOCKED (the dependent feature whose + * `dependsOnFeatureIds` array will gain the blocker's id). Returns + * `null` for any other shape — milestone↔feature edges are caught + * by `detectFeatureMilestoneEdge` above; the milestone check runs + * first in `handleEdgeAdd`. + */ + const detectFeatureBlocksEdge = useCallback( + ( + edge: CanvasEdge, + ): { blockerId: string; blockedId: string } | null => { + const a = edge.fromNode; + const b = edge.toNode; + if (a.startsWith("feature:") && b.startsWith("feature:")) { + return { + blockerId: a.slice("feature:".length), + blockedId: b.slice("feature:".length), + }; + } + return null; + }, + [], + ); + + /** + * Fire-and-forget PATCH that adds or removes a blocker on a + * feature's `dependsOnFeatureIds` array. Read-modify-write because + * the column is a flat string array; the service-layer + * `updateFeature` runs validation + the cycle check before writing. + * Pass `mode: "add"` to append (no-op if already present) or + * `"remove"` to filter out. The Pusher fan-out (reused + * `notifyFeatureContentRefresh`) handles the canvas refresh. + */ + const patchFeatureBlocks = useCallback( + async ( + blockedId: string, + blockerId: string, + mode: "add" | "remove", + ) => { + try { + // Read current array first — `dependsOnFeatureIds` is a flat + // column and the API expects the full replacement set. + const readRes = await fetch(`/api/features/${blockedId}`); + if (!readRes.ok) { + console.error( + "[OrgCanvasBackground] patchFeatureBlocks read failed", + readRes.status, + ); + return; + } + const readBody = await readRes.json(); + const current: string[] = + (readBody?.data?.dependsOnFeatureIds as string[] | undefined) ?? []; + const next = + mode === "add" + ? Array.from(new Set([...current, blockerId])) + : current.filter((id) => id !== blockerId); + // Skip no-op writes — saves a round trip and avoids + // emitting CANVAS_UPDATED for a write that didn't change + // anything. + if ( + next.length === current.length && + next.every((id, i) => id === current[i]) + ) { + return; + } + const res = await fetch(`/api/features/${blockedId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ dependsOnFeatureIds: next }), + }); + if (!res.ok) { + const detail = await res.text().catch(() => ""); + console.error( + "[OrgCanvasBackground] patchFeatureBlocks failed", + res.status, + detail, + ); + toast.error("Failed to update dependency"); + } + } catch (err) { + console.error("[OrgCanvasBackground] patchFeatureBlocks threw", err); + toast.error("Failed to update dependency"); + } + }, + [], + ); + + const handleEdgeAdd = useCallback( + (edge: CanvasEdge, canvasRef: string | undefined) => { + // Intercept feature↔milestone edges as DB membership writes + // rather than authored-blob edges. The relationship is owned by + // `Feature.milestoneId`; the projector emits a synthetic edge + // on every read to visually represent it. Letting the + // user-drawn edge sit in the blob alongside the synthetic one + // would create two parallel representations that can disagree + // when the DB is mutated through other paths. + // + // To avoid the user seeing their edge "disappear and reappear" + // across the PATCH + Pusher round-trip, we **rename** the + // edge's id in place to the predicted synthetic id rather than + // removing it. Two consequences fall out for free: + // 1. The autosave splitter (`splitCanvas`) filters out + // `synthetic:` ids, so this optimistic edge is never + // persisted into the blob — DB membership stays the only + // source of truth. + // 2. When the Pusher-driven refetch lands ~hundreds of ms + // later carrying the real projector-emitted synthetic + // edge, it has the *same* id and endpoints, so React's + // diff is a no-op — the user sees a continuous edge + // throughout. + // The id format must match what `milestoneTimelineProjector` + // emits: `synthetic:feature-milestone:`. Drift here + // would silently double-render edges across the round-trip. + const link = detectFeatureMilestoneEdge(edge); + if (link) { + const syntheticId = `synthetic:feature-milestone:${link.featureId}`; + // Canonicalize the optimistic edge to match exactly what + // `milestoneTimelineProjector` will emit on the next refetch + // — same id, same endpoint order (feature→milestone), and + // crucially **no `fromSide`/`toSide`** so the library's + // auto-router routes both versions identically. With matching + // shape, the Pusher-driven swap is a React diff no-op and + // the user sees a continuous edge throughout the round-trip. + // Synthetic edges are DB-derived layout, not user-authored; + // we intentionally don't preserve which handle the user + // happened to click — trust the auto-router. + applyMutation(canvasRef, (c) => { + const withoutTemp = removeEdge(c, edge.id); + // Skip if the projector-emitted edge is already in the + // canvas (e.g. user re-drew an edge that already exists). + // Adding a duplicate id would crash the library. + if (withoutTemp.edges?.some((e) => e.id === syntheticId)) { + return withoutTemp; + } + return addEdge(withoutTemp, { + id: syntheticId, + fromNode: `feature:${link.featureId}`, + toNode: `milestone:${link.milestoneId}`, + } as CanvasEdge); + }); + void patchFeatureMilestone(link.featureId, link.milestoneId); + return; + } + + // Feature→feature dependency edge (the "blocks" relation). Same + // pattern as the milestone interception: swap the user-drawn + // edge's id for the predicted synthetic id, PATCH the DB, let + // the Pusher-driven refetch confirm via the projector. The + // server-side cycle check in `updateFeature` rejects the write + // if it would create a cycle — the optimistic edge then sits + // briefly until the next refetch removes it (the projector + // won't re-emit a non-persisted dependency). + const blocksLink = detectFeatureBlocksEdge(edge); + if (blocksLink && blocksLink.blockerId !== blocksLink.blockedId) { + const syntheticId = + `synthetic:feature-blocks:${blocksLink.blockerId}:${blocksLink.blockedId}`; + applyMutation(canvasRef, (c) => { + const withoutTemp = removeEdge(c, edge.id); + if (withoutTemp.edges?.some((e) => e.id === syntheticId)) { + return withoutTemp; + } + return addEdge(withoutTemp, { + id: syntheticId, + fromNode: `feature:${blocksLink.blockerId}`, + toNode: `feature:${blocksLink.blockedId}`, + customData: { kind: "blocks" }, + } as CanvasEdge); + }); + void patchFeatureBlocks( + blocksLink.blockedId, + blocksLink.blockerId, + "add", + ); + return; + } + + applyMutation(canvasRef, (c) => addEdge(c, edge)); + }, + [ + applyMutation, + detectFeatureMilestoneEdge, + patchFeatureMilestone, + detectFeatureBlocksEdge, + patchFeatureBlocks, + ], + ); + const handleEdgeUpdate = useCallback( + (id: string, patch: EdgeUpdate, canvasRef: string | undefined) => { + // Synthetic edges (DB-projected feature→milestone membership) are + // not in the blob, so library `updateEdge` calls against them + // would no-op silently. The most common "update" on a synthetic + // edge would be repointing one endpoint — that's a membership + // change, which belongs as a PATCH. Defensive guard: ignore + // patches addressed at synthetic ids. + if (id.startsWith("synthetic:")) return; + applyMutation(canvasRef, (c) => updateEdge(c, id, patch)); + }, + [applyMutation], + ); + const handleEdgeDelete = useCallback( + (id: string, canvasRef: string | undefined) => { + // Deleting a synthetic feature→milestone membership edge means + // "this feature is no longer in this milestone" — a DB write, + // not a blob delete. PATCH `milestoneId: null`; the projector + // re-runs and the synthetic edge disappears. + // + // We ALSO optimistically remove it from local state so the + // user sees the edge disappear immediately rather than + // lingering until the Pusher refetch lands. `splitCanvas` + // filters `synthetic:` ids so this removal doesn't produce an + // extraneous authored-edge delete in the persisted blob. + if (id.startsWith("synthetic:feature-milestone:")) { + const featureId = id.slice("synthetic:feature-milestone:".length); + applyMutation(canvasRef, (c) => removeEdge(c, id)); + void patchFeatureMilestone(featureId, null); + return; + } + // Deleting a synthetic dependency edge means "this feature no + // longer depends on that one." Same posture as the milestone + // case: optimistic local remove, then PATCH the + // `dependsOnFeatureIds` array minus the blocker. Id format: + // `synthetic:feature-blocks::`. + if (id.startsWith("synthetic:feature-blocks:")) { + const ids = id.slice("synthetic:feature-blocks:".length).split(":"); + if (ids.length === 2) { + const [blockerId, blockedId] = ids; + applyMutation(canvasRef, (c) => removeEdge(c, id)); + void patchFeatureBlocks(blockedId, blockerId, "remove"); + return; + } + } + applyMutation(canvasRef, (c) => removeEdge(c, id)); + }, + [applyMutation, patchFeatureMilestone, patchFeatureBlocks], + ); + + // Expose the edge-patch path through the parent-supplied ref so + // sibling surfaces (the Connections-tab link mode) can write + // `customData.connectionId` without prop-drilling a callback + // through every list row. Re-wire on every render so the latest + // closure is always exposed (the ref's identity is stable, the + // function it points at is not — that's the desired semantics). + useEffect(() => { + if (!edgePatchHandleRef) return; + edgePatchHandleRef.current = handleEdgeUpdate; + return () => { + // Null out on unmount so a stale handler can't fire after the + // canvas tears down. + if (edgePatchHandleRef.current === handleEdgeUpdate) { + edgePatchHandleRef.current = null; + } + }; + }, [edgePatchHandleRef, handleEdgeUpdate]); + + // ------------------------------------------------------------------- + // Drop-on-node — three pairings today: + // + // 1. **Feature → Milestone (DB reassign).** Drag a `feature:` card + // onto a `milestone:` card to PATCH `Feature.milestoneId`. The + // server derives `initiativeId` and fans out CANVAS_UPDATED on + // every affected canvas; the projector re-emits the feature on + // the most-specific scope. + // + // 2. **Authored callout → Live container (canvas move).** Drag a + // `note` / `decision` / base-`text` node onto a `workspace:` / + // `initiative:` / `milestone:` card to MOVE the authored node + // from its current canvas blob onto the target's sub-canvas + // blob. Lets the user accumulate loose thoughts on the root + // canvas, then organize them under the workspace / initiative / + // milestone they belong to without retyping. The note's text + + // category + customData survive the move (it's a blob-to-blob + // hop, not a DB write). + // + // 3. **Research → Initiative (DB reassign).** Drag a `research:` + // card onto an `initiative:` card to PATCH + // `Research.initiativeId`. The row jumps from the root canvas + // to the initiative sub-canvas (or between two initiative + // sub-canvases) on the next projector run. Drop coords are + // intentionally NOT preserved — the source and target live on + // different canvases, so the source's `(x, y)` has no meaning + // on the destination; the projector's default initiative-canvas + // slot is the right landing spot. Symmetric "unscope to root" + // isn't supported via drop today (root has no container card + // to drop onto); a future right-click menu or "drop on empty + // canvas" gesture can cover it. + // + // Library-side hooks: `canDropNodeOn` is the per-frame predicate + // during drag (must be cheap — id-prefix sniff + category check, no + // fetches, no setState); `onNodeDrop` is the release handler. The + // library snaps the source back to its pre-drag position before + // firing `onNodeDrop`, so we never commit a canvas-position update. + // + // Self-drop is filtered library-side, but we keep an explicit + // `source.id !== target.id` line for defensive readability. + // + // The lookup arrays (`AUTHORED_DROPPABLE_CATEGORIES`, + // `LIVE_CONTAINER_CATEGORIES`) live at module scope above so + // `useCallback` deps stay stable. + // ------------------------------------------------------------------- + const canDropNodeOn = useCallback( + (sources: CanvasNode[], target: CanvasNode): boolean => { + // Accept if at least one source forms a valid pairing with the + // target. This enables multi-select drops where only some of the + // dragged nodes are droppable onto the target. + return sources.some((source) => { + if (!source || source.id === target.id) return false; + + // Pairing 1: feature → milestone (DB reassign). + if ( + source.category === "feature" && + target.category === "milestone" && + source.id.startsWith("feature:") && + target.id.startsWith("milestone:") + ) { + return true; + } + + // Pairing 2: authored callout → live container (canvas move). + // The source must be authored (not a live id) and the target + // must be a container with a sub-canvas to land in. + if ( + AUTHORED_DROPPABLE_CATEGORIES.includes(source.category ?? "") && + !isLiveId(source.id) && + LIVE_CONTAINER_CATEGORIES.includes(target.category ?? "") && + isLiveId(target.id) + ) { + return true; + } + + // Pairing 3: research → initiative (DB reassign). Mirrors + // pairing 1 structurally — the gesture targets a DB column, + // not the canvas blob, so the synthetic split-between-canvases + // happens via the projector after the fan-out. + if ( + source.category === "research" && + target.category === "initiative" && + source.id.startsWith("research:") && + target.id.startsWith("initiative:") + ) { + return true; + } + + return false; + }); + }, + [], + ); + + /** + * Fire-and-forget PATCH that reassigns a feature to a different + * milestone. The server derives the new `initiativeId` from the + * milestone (via the coherence rule we shipped in `updateFeature`) + * and fans out `CANVAS_UPDATED` on every affected canvas (root, + * both initiatives, both milestones, workspace). We don't refetch + * locally — the Pusher fan-out handler in this component already + * pulls fresh data for whichever canvas the user is currently + * looking at, plus root. + * + * On error we surface to the console; the user's drop visually + * snapped back (the library handled that), so there's no stuck + * "ghost card" to clean up. A toast belongs here when we add the + * global toast system. + */ + const reassignFeatureToMilestone = useCallback( + async (featureId: string, milestoneId: string) => { + try { + const res = await fetch(`/api/features/${featureId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ milestoneId }), + }); + if (!res.ok) { + const detail = await res.text().catch(() => ""); + console.error( + "[OrgCanvasBackground] reassign feature failed", + res.status, + detail, + ); + toast.error("Failed to reassign feature"); + } + } catch (err) { + console.error( + "[OrgCanvasBackground] reassign feature threw", + err, + ); + toast.error("Failed to reassign feature"); + } + }, + [], + ); + + /** + * Fire-and-forget PATCH that reassigns a research row to a different + * initiative (or to root, with `initiativeId: null`). Same posture + * as `reassignFeatureToMilestone`: no local refetch — the server + * fans out `CANVAS_UPDATED` on both source and target refs via + * `notifyResearchReassignmentRefresh`, and this component's Pusher + * handler picks up the relevant canvas. + * + * On error we surface to the console; the library snapped the drop + * back, so there's no stuck "ghost card" to clean up. A toast + * belongs here when the global toast system lands. + */ + const reassignResearchToInitiative = useCallback( + async (researchId: string, initiativeId: string | null) => { + try { + const res = await fetch( + `/api/orgs/${githubLogin}/research/${researchId}`, + { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ initiativeId }), + }, + ); + if (!res.ok) { + const detail = await res.text().catch(() => ""); + console.error( + "[OrgCanvasBackground] reassign research failed", + res.status, + detail, + ); + toast.error("Failed to reassign research"); + } + } catch (err) { + console.error( + "[OrgCanvasBackground] reassign research threw", + err, + ); + toast.error("Failed to reassign research"); + } + }, + [githubLogin], + ); + + /** + * Move an authored node from the source canvas blob to the target's + * sub-canvas blob. Two side-effects: + * + * 1. **Remove** the node from `currentRef` (the canvas the user is + * looking at). Routes through `applyMutation` so it's + * optimistic, undoable via Ctrl-Z, and rides the standard + * autosave flush that already covers in-flight blob writes. + * + * 2. **Add** the node to the target's sub-canvas via a direct + * read-modify-write PUT. The target canvas may not be cached + * locally (the user might never have drilled into it), so + * we use the same pattern as `savePositionForLiveId`: fetch + * the latest blob, append the node, PUT it back. The PUT + * emits `CANVAS_UPDATED` server-side; both canvases (the one + * we removed from + the one we added to) refetch via the + * existing Pusher handler, so the user sees the move land on + * the target if they drill in. + * + * The node's `id` / `text` / `category` / `customData` survive the + * move verbatim. Position is preserved as-is from the source — the + * user can drag it once they drill into the target. Resetting `(x, + * y)` to a projector-default would be marginally nicer but means + * computing each target's empty-canvas anchor; not worth the + * complexity for v1. + * + * On a server-side write failure, the source-canvas remove already + * landed locally. The autosave will eventually flush that removal, + * leaving the user with a "lost note" if the target write also + * failed. Acceptable for v1 (Ctrl-Z undoes the source removal); a + * proper rollback would need the consumer-side undo stack to track + * cross-canvas paired actions, which is overkill here. + */ + const moveAuthoredNodesToCanvas = useCallback( + async ( + sourceCanvasRef: string | undefined, + sourceNodes: CanvasNode[], + targetCanvasRef: string, + ) => { + if (sourceNodes.length === 0) return; + + const movedIds = new Set(sourceNodes.map((n) => n.id)); + applyMutation(sourceCanvasRef, (c) => { + let next = c; + for (const id of movedIds) next = removeNode(next, id); + return next; + }); + + try { + const url = `/api/orgs/${githubLogin}/canvas/${encodeURIComponent(targetCanvasRef)}`; + const res = await fetch(url); + if (!res.ok) { + console.error( + "[useCanvasEdgeOps] moveAuthoredNodesToCanvas read failed", + res.status, + ); + toast.error("Failed to move node"); + return; + } + const body = await res.json(); + const data: CanvasData = body.data ?? { nodes: [], edges: [] }; + const existingNodes = data.nodes ?? []; + const nextNodes: CanvasNode[] = [ + ...existingNodes.filter((n) => !movedIds.has(n.id)), + ...sourceNodes, + ]; + const putRes = await fetch(url, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ data: { ...data, nodes: nextNodes } }), + }); + if (!putRes.ok) { + console.error( + "[useCanvasEdgeOps] moveAuthoredNodesToCanvas write failed", + putRes.status, + ); + toast.error("Failed to move node"); + } + } catch (err) { + console.error( + "[useCanvasEdgeOps] moveAuthoredNodesToCanvas threw", + err, + ); + toast.error("Failed to move node"); + } + }, + [applyMutation, githubLogin], + ); + + const handleNodeDrop = useCallback( + ( + sources: CanvasNode[], + target: CanvasNode, + ctx: { canvasRef: string | undefined }, + ) => { + const authoredToMove: CanvasNode[] = []; + + for (const source of sources) { + if (!source) continue; + + if ( + source.category === "feature" && + target.category === "milestone" && + source.id.startsWith("feature:") && + target.id.startsWith("milestone:") + ) { + const featureId = source.id.slice("feature:".length); + const milestoneId = target.id.slice("milestone:".length); + void reassignFeatureToMilestone(featureId, milestoneId); + continue; + } + + if ( + AUTHORED_DROPPABLE_CATEGORIES.includes(source.category ?? "") && + !isLiveId(source.id) && + LIVE_CONTAINER_CATEGORIES.includes(target.category ?? "") && + target.ref + ) { + authoredToMove.push(source); + continue; + } + + if ( + source.category === "research" && + target.category === "initiative" && + source.id.startsWith("research:") && + target.id.startsWith("initiative:") + ) { + const researchId = source.id.slice("research:".length); + const initiativeId = target.id.slice("initiative:".length); + void reassignResearchToInitiative(researchId, initiativeId); + continue; + } + } + + if (authoredToMove.length > 0 && target.ref) { + void moveAuthoredNodesToCanvas(ctx.canvasRef, authoredToMove, target.ref); + } + }, + [ + reassignFeatureToMilestone, + moveAuthoredNodesToCanvas, + reassignResearchToInitiative, + ], + ); + + return { + handleEdgeAdd, + handleEdgeUpdate, + handleEdgeDelete, + canDropNodeOn, + handleNodeDrop, + }; +} diff --git a/src/app/org/[githubLogin]/connections/useCanvasHiddenLive.ts b/src/app/org/[githubLogin]/connections/useCanvasHiddenLive.ts new file mode 100644 index 0000000000..1e48f00e07 --- /dev/null +++ b/src/app/org/[githubLogin]/connections/useCanvasHiddenLive.ts @@ -0,0 +1,162 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import type { CanvasData } from "system-canvas-react"; +import type { HiddenLiveEntry } from "./HiddenLivePill"; +import { fetchRoot, fetchSub } from "./useCanvasPersistence"; + +export async function toggleLiveVisibility( + githubLogin: string, + ref: string | undefined, + id: string, + action: "hide" | "show", +): Promise { + const res = await fetch(`/api/orgs/${githubLogin}/canvas/hide`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ref: ref ?? "", id, action }), + }); + if (!res.ok) { + console.error( + `[useCanvasHiddenLive] ${action} failed for ${id} (${res.status})`, + ); + } +} + +export async function fetchHiddenLive( + githubLogin: string, + ref: string | undefined, +): Promise { + const qs = ref ? `?ref=${encodeURIComponent(ref)}` : ""; + const res = await fetch(`/api/orgs/${githubLogin}/canvas/hide${qs}`); + if (!res.ok) { + console.error( + `[useCanvasHiddenLive] fetchHiddenLive failed (${res.status})`, + ); + return []; + } + const body = await res.json(); + return (body.entries ?? []) as HiddenLiveEntry[]; +} + +interface UseCanvasHiddenLiveOptions { + githubLogin: string; + currentRef: string; + currentRefRef: React.RefObject; + setRoot: React.Dispatch>; + setSubCanvases: React.Dispatch>>; + applyMutation: ( + canvasRef: string | undefined, + mutate: (data: CanvasData) => CanvasData, + ) => void; + onHiddenChange?: (entries: HiddenLiveEntry[]) => void; +} + +interface UseCanvasHiddenLiveReturn { + hiddenLive: HiddenLiveEntry[] | null; + rootHiddenLive: HiddenLiveEntry[] | null; + refreshHiddenLive: () => void; + refreshRootHiddenLive: () => void; + handleRestoreLive: (id: string) => Promise; +} + +export function useCanvasHiddenLive({ + githubLogin, + currentRef, + currentRefRef, + setRoot, + setSubCanvases, + onHiddenChange, +}: UseCanvasHiddenLiveOptions): UseCanvasHiddenLiveReturn { + const [hiddenLive, setHiddenLive] = useState(null); + const [rootHiddenLive, setRootHiddenLive] = useState(null); + + // Initial root hidden-list fetch. + useEffect(() => { + let cancelled = false; + fetchHiddenLive(githubLogin, undefined).then((entries) => { + if (cancelled) return; + setRootHiddenLive(entries); + setHiddenLive(entries); + }); + return () => { + cancelled = true; + }; + }, [githubLogin]); + + // Refetch hidden list when user drills into a sub-canvas. + useEffect(() => { + if (currentRef === "") return; + let cancelled = false; + setHiddenLive(null); + fetchHiddenLive(githubLogin, currentRef).then((entries) => { + if (!cancelled) setHiddenLive(entries); + }); + return () => { + cancelled = true; + }; + }, [githubLogin, currentRef]); + + const refreshHiddenLive = useCallback(() => { + const ref = currentRefRef.current; + fetchHiddenLive(githubLogin, ref === "" ? undefined : ref).then( + setHiddenLive, + ); + }, [githubLogin, currentRefRef]); + + const refreshRootHiddenLive = useCallback(() => { + fetchHiddenLive(githubLogin, undefined).then((entries) => { + setRootHiddenLive(entries); + if (currentRefRef.current === "") setHiddenLive(entries); + }); + }, [githubLogin, currentRefRef]); + + // Notify parent when root hidden-live set changes. + useEffect(() => { + if (rootHiddenLive === null) return; + onHiddenChange?.(rootHiddenLive); + }, [rootHiddenLive, onHiddenChange]); + + const handleRestoreLive = useCallback( + async (id: string) => { + const ref = currentRefRef.current; + const isRoot = ref === ""; + setHiddenLive((prev) => + prev === null ? prev : prev.filter((e) => e.id !== id), + ); + if (isRoot) { + setRootHiddenLive((prev) => + prev === null ? prev : prev.filter((e) => e.id !== id), + ); + } + await toggleLiveVisibility( + githubLogin, + isRoot ? undefined : ref, + id, + "show", + ); + try { + if (isRoot) { + const data = await fetchRoot(githubLogin); + setRoot(data); + } else { + const data = await fetchSub(githubLogin, ref); + setSubCanvases((prev) => ({ ...prev, [ref]: data })); + } + } catch (err) { + console.error("[useCanvasHiddenLive] refetch after restore failed", err); + refreshHiddenLive(); + if (isRoot) refreshRootHiddenLive(); + } + }, + [githubLogin, currentRefRef, setRoot, setSubCanvases, refreshHiddenLive, refreshRootHiddenLive], + ); + + return { + hiddenLive, + rootHiddenLive, + refreshHiddenLive, + refreshRootHiddenLive, + handleRestoreLive, + }; +} diff --git a/src/app/org/[githubLogin]/connections/useCanvasNodeOps.ts b/src/app/org/[githubLogin]/connections/useCanvasNodeOps.ts new file mode 100644 index 0000000000..17af55fd57 --- /dev/null +++ b/src/app/org/[githubLogin]/connections/useCanvasNodeOps.ts @@ -0,0 +1,1144 @@ +"use client"; + +import { useCallback, useRef, useState } from "react"; +import { + addNode, + removeNode, + updateNode, + type CanvasData, + type CanvasNode, + type NodeUpdate, +} from "system-canvas-react"; +import { toast } from "sonner"; +import { isLiveId } from "@/lib/canvas"; +import { toggleLiveVisibility } from "./useCanvasHiddenLive"; +import { fetchRoot, fetchSub } from "./useCanvasPersistence"; +import { + type InitiativeForm, +} from "@/components/initiatives/InitiativeDialog"; +import { + type MilestoneForm, +} from "@/components/initiatives/MilestoneDialog"; +import { + type FeatureCreateForm, + type FeatureAssignForm, +} from "../_components/CreateFeatureCanvasDialog"; +import type { + InitiativeResponse, + MilestoneResponse, +} from "@/types/initiatives"; +import { useCanvasChatStore } from "../_state/canvasChatStore"; +import { useSendCanvasChatMessage } from "../_state/useSendCanvasChatMessage"; +import { categoryAllowedOnScope } from "./canvas-categories"; + + +// ─────────────────────────────────────────────────────────────────────── +// Module-level constant (moved from OrgCanvasBackground) +// ─────────────────────────────────────────────────────────────────────── + +/** + * Categories that, when picked from the `+` menu, open a creation + * dialog instead of dropping a node onto the canvas. The dialog hits + * the appropriate REST API; on success the projector re-emits the new + * node, and we save the user's click position so the node lands where + * they clicked. + * + * Whether each category is *visible* in the `+` menu on the current + * scope is decided by `categoryAllowedOnScope` in `canvas-categories.ts` + * — see `renderAddNodeButton` below. Both filters consult the same + * helper so a category never shows in the menu but fails the dispatch + * (or vice versa). + */ +export const DB_CREATING_CATEGORIES = new Set(["initiative", "milestone", "feature"]); + +// ─────────────────────────────────────────────────────────────────────── +// PendingAdd types +// ─────────────────────────────────────────────────────────────────────── + +/** + * The `canvasRef` in every variant tells us which canvas the user + * triggered from — root (`undefined`), a workspace (`ws:`), or an + * initiative (`initiative:`). The distinction matters because + * initiatives, `initiative:` for milestones. + */ +export type PendingInitiativeAdd = { + kind: "initiative"; + x: number; + y: number; + canvasRef: string | undefined; +}; +export type PendingMilestoneAdd = { + kind: "milestone"; + x: number; + y: number; + /** Always an `initiative:` ref — milestones can only be added inside one. */ + canvasRef: string; + initiativeId: string; + /** Count + 1 from the server; undefined if fetch failed (dialog opens with empty field). */ + defaultSequence?: number; +}; +/** + * Pending feature-create from either the `+` menu or a "Promote to + * Feature" right-click on a note. `canvasRef` is the scope the user + * triggered from — the dialog uses it to lock fields. `prefill` is + * set on the Promote path; the note's text seeds title + brief. + */ +export type PendingFeatureAdd = { + kind: "feature"; + x: number; + y: number; + /** Empty string ("") means root canvas; otherwise the sub-canvas ref. */ + canvasRef: string | undefined; + prefill?: { title?: string; brief?: string }; + /** + * If set, the dialog was opened by promoting a note. After save we + * delete the source note from its canvas (the user "consumed" it). + */ + sourceNoteId?: string; +}; +/** + * Pending service-create from the `+ Service` menu pick on a workspace + * sub-canvas. Unlike initiative / milestone / feature, services are + * NOT DB-projected — the new node lands directly in the canvas blob + * via `applyMutation` once the dialog returns. We carry only the click + * position and the source canvas ref through; the dialog supplies + * the name + platform kind. + * + * `node` is the freshly-synthesized authored node the library handed + * us (carrying the lib-generated id, default size from the category, + * and the click x/y). We hold onto it so we can drop the dialog's + * name + kind into it on save without re-synthesizing the id. + */ +export type PendingServiceAdd = { + kind: "service"; + node: CanvasNode; + canvasRef: string | undefined; +}; +export type PendingAdd = + | PendingInitiativeAdd + | PendingMilestoneAdd + | PendingFeatureAdd + | PendingServiceAdd; + +// ─────────────────────────────────────────────────────────────────────── +// Options interface +// ─────────────────────────────────────────────────────────────────────── + +interface UseCanvasNodeOpsOptions { + githubLogin: string; + currentRefRef: React.RefObject; + rootRef: React.RefObject; + subCanvasesRef: React.RefObject>; + applyMutation: (canvasRef: string | undefined, mutate: (data: CanvasData) => CanvasData) => void; + setRoot: React.Dispatch>; + setSubCanvases: React.Dispatch>>; + refreshHiddenLive: () => void; + refreshRootHiddenLive: () => void; +} + +// ─────────────────────────────────────────────────────────────────────── +// Hook +// ─────────────────────────────────────────────────────────────────────── + +export function useCanvasNodeOps({ + githubLogin, + currentRefRef, + rootRef, + subCanvasesRef, + applyMutation, + setRoot, + setSubCanvases, + refreshHiddenLive, + refreshRootHiddenLive, +}: UseCanvasNodeOpsOptions) { + const [pendingAdd, setPendingAdd] = useState(null); + + /** + * Save a freshly-created live node's click position into the canvas + * blob so it lands where the user clicked. Fire-and-forget: the API + * response from POST already returned the new id, so we can write + * the position before the projector re-emits the node — by the time + * Pusher fires the refresh, the position overlay is already in place. + */ + const savePositionForLiveId = useCallback( + async ( + canvasRef: string | undefined, + liveId: string, + x: number, + y: number, + ) => { + try { + // Read-modify-write the relevant canvas. We read first so we + // don't clobber other position overlays the user already set. + const url = canvasRef + ? `/api/orgs/${githubLogin}/canvas/${encodeURIComponent(canvasRef)}` + : `/api/orgs/${githubLogin}/canvas`; + const res = await fetch(url); + if (!res.ok) return; + const body = await res.json(); + const data: CanvasData = body.data ?? { nodes: [], edges: [] }; + // Append a stub node carrying the position. The server's + // splitter will treat its live id as a positions overlay and + // discard everything else (text/category/customData). It + // doesn't matter that the projector hasn't emitted the real + // node yet — the position survives independently. + const existingNodes = data.nodes ?? []; + const nextNodes: CanvasNode[] = [ + ...existingNodes.filter((n) => n.id !== liveId), + { + id: liveId, + type: "text", + category: "", + text: "", + x, + y, + }, + ]; + await fetch(url, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ data: { ...data, nodes: nextNodes } }), + }); + } catch (err) { + // Non-fatal: the node will appear at the projector's default + // position and the user can drag it. + console.error( + "[OrgCanvasBackground] savePositionForLiveId failed", + err, + ); + } + }, + [githubLogin], + ); + + // =================================================================== + // Research kickoff + // =================================================================== + // + // The Research feature is the first canvas node type with an + // **authored→live** lifecycle. The user picks `+ Research` from the + // menu, types a topic into the dropped node, and on text commit the + // node fires a synthetic chat message that drives the agent's + // `save_research` tool (see `src/lib/ai/researchTools.ts`). + // + // **The authored→live swap is NOT a client-side concern.** Earlier + // versions of this feature tried to swap in the client (FIFO + // queue + processed-id set + `applyMutation(removeNode)` + various + // race-y refetch sequences). It never converged — autosave, pusher + // refetch, and the position-overlay write all fought each other, + // and any failure left a phantom authored node in the canvas blob. + // + // The swap now happens **at the IO boundary** in + // `src/lib/canvas/io.ts` (`dedupeAuthoredResearch`): on every read + // and every write, an authored `research` node whose text matches + // a live `research:` node's text is dropped, with its position + // carried into the live node's overlay if no overlay exists. That + // gives us the visible swap on the user's next canvas refresh + // (which Pusher triggers within ~300ms of `save_research` + // returning) and self-heals the persisted blob on the next + // autosave. No client-side queue, no race conditions, no phantom + // nodes. + // + // The only client-side work is firing the kickoff. Tracking which + // authored node ids we've already kicked off prevents a double-fire + // if the user hits Enter twice on the same node before the swap + // happens. + + /** + * Authored research node ids whose kickoff we've already fired. + * The library prefills new nodes with placeholder text ("New + * node") rather than an empty string, so we can't use + * "empty→non-empty" as the trigger. Instead: fire the kickoff the + * first time the user-typed text differs from whatever was there + * before AND we haven't already fired for this id. + */ + const firedResearchKickoffsRef = useRef>(new Set()); + + const sendCanvasChatMessage = useSendCanvasChatMessage(); + + /** + * Fire the synthetic user message that drives the agent. + * + * The store's `activeConversationId` may legitimately be null for a + * brief window during initial mount (before `OrgCanvasView` calls + * `startConversation`). In that case we drop the kickoff silently + * — the user can hit Enter again once the chat is ready, or just + * type the request directly. We don't want to retry-loop the + * kickoff because that'd race with the user editing the node. + */ + const fireResearchKickoff = useCallback( + (topic: string) => { + const trimmedTopic = topic.trim(); + if (!trimmedTopic) return; + const conversationId = + useCanvasChatStore.getState().activeConversationId; + if (!conversationId) { + console.warn( + "[OrgCanvasBackground] research kickoff fired before chat conversation was ready; user will need to retry or type the request manually", + ); + return; + } + // Format that the prompt suffix instructs the agent to recognize + // ("if you see a synthetic user message of the form 'Research: + // ', that's the signal"). The agent extracts the topic + // and is told to pass it as `topic` verbatim into save_research, + // which is what makes the IO-layer text-equality dedupe work. + void sendCanvasChatMessage({ + conversationId, + content: `Research: ${trimmedTopic}`, + }); + }, + [sendCanvasChatMessage], + ); + + /** + * Open the InitiativeDialog with the click position cached. Caller + * passes the synthetic node from the library so we can pull `x`/`y` + * off it. + */ + const startInitiativeCreate = useCallback( + (node: CanvasNode, canvasRef: string | undefined) => { + setPendingAdd({ + kind: "initiative", + x: node.x, + y: node.y, + canvasRef, + }); + }, + [], + ); + + /** + * Open the MilestoneDialog. Pre-fetch the initiative's existing + * milestones so we know which sequence numbers are taken (needed + * for the dialog's client-side validation) and what the next-free + * sequence is to pre-populate the form. + * + * Caller (handleNodeAdd) must already have validated the scope — + * this function trusts that `canvasRef` is `initiative:`. + */ + const startMilestoneCreate = useCallback( + async (node: CanvasNode, canvasRef: string) => { + const initiativeId = canvasRef.slice("initiative:".length); + try { + const res = await fetch( + `/api/orgs/${githubLogin}/initiatives/${initiativeId}/milestones`, + ); + const data = res.ok ? await res.json() : null; + const defaultSequence = data ? data.count + 1 : undefined; + setPendingAdd({ + kind: "milestone", + x: node.x, + y: node.y, + canvasRef, + initiativeId, + defaultSequence, + }); + } catch (err) { + console.error( + "[OrgCanvasBackground] failed to fetch milestones for dialog seed", + err, + ); + setPendingAdd({ + kind: "milestone", + x: node.x, + y: node.y, + canvasRef, + initiativeId, + defaultSequence: undefined, + }); + } + }, + [githubLogin], + ); + + /** + * Open the CreateFeatureCanvasDialog. Unlike initiative/milestone + * dialogs, feature creation has no scope-bound pre-fetch: the dialog + * itself loads workspaces / initiatives / edge hints lazily on open. + * The caller passes `prefill` only when this is a promote-from-note + * path (so the dialog seeds its title + description). + */ + const startFeatureCreate = useCallback( + ( + node: CanvasNode, + canvasRef: string | undefined, + prefill?: { title?: string; brief?: string }, + sourceNoteId?: string, + ) => { + setPendingAdd({ + kind: "feature", + x: node.x, + y: node.y, + canvasRef, + prefill, + sourceNoteId, + }); + }, + [], + ); + + /** + * Open the CreateServiceCanvasDialog. Service is authored-only (no + * DB row), so this just stashes the lib-synthesized node and waits + * for the dialog to fill in name + platform `kind` — at which point + * `handleSaveService` writes the node to the canvas blob via + * `applyMutation`. Caller (handleNodeAdd) must have validated scope. + */ + const startServiceCreate = useCallback( + (node: CanvasNode, canvasRef: string | undefined) => { + setPendingAdd({ kind: "service", node, canvasRef }); + }, + [], + ); + + const handleNodeAdd = useCallback( + (node: CanvasNode, canvasRef: string | undefined) => { + // Live-node re-add — fired by the library's undo when reversing a + // hide. Add the node back locally for instant display, then tell the + // server to un-hide it so the projector picks it up on next fetch. + if (isLiveId(node.id)) { + applyMutation(canvasRef, (c) => addNode(c, node)); + void toggleLiveVisibility(githubLogin, canvasRef, node.id, "show").then( + () => { + if (!canvasRef) { + refreshRootHiddenLive(); + } else if (canvasRef === currentRefRef.current) { + refreshHiddenLive(); + } + }, + ); + return; + } + + const category = node.category ?? ""; + const ref = canvasRef ?? ""; + + // DB-creating categories are routed through their dialog. The + // scope-aware `+` menu filter shouldn't have offered the option + // outside its valid scope, but check here too — a stale menu + // pick (e.g. user navigated mid-click) shouldn't create at the + // wrong scope. Single rule, two enforcement points. + if (DB_CREATING_CATEGORIES.has(category)) { + if (!categoryAllowedOnScope(category, ref)) return; + if (category === "initiative") { + startInitiativeCreate(node, canvasRef); + return; + } + if (category === "milestone") { + // categoryAllowedOnScope guarantees ref starts with "initiative:". + void startMilestoneCreate(node, ref); + return; + } + if (category === "feature") { + // Allowed on every scope per `categoryAllowedOnScope`. The + // dialog handles per-scope field locking; we just hand off + // the click position. + startFeatureCreate(node, canvasRef); + return; + } + } + + // Service is authored-only (no DB row, no projector) but still + // routes through a dialog so the user picks a platform icon up + // front. We don't add it to DB_CREATING_CATEGORIES — that set's + // semantic is "needs a REST POST"; service skips the API entirely + // and lands in the canvas blob via `applyMutation` once the dialog + // returns. Still scope-guarded against a stale menu pick. + if (category === "service") { + if (!categoryAllowedOnScope(category, ref)) return; + startServiceCreate(node, canvasRef); + return; + } + + applyMutation(canvasRef, (c) => addNode(c, node)); + }, + [ + applyMutation, + githubLogin, + refreshHiddenLive, + refreshRootHiddenLive, + startInitiativeCreate, + startMilestoneCreate, + startFeatureCreate, + startServiceCreate, + ], + ); + + // ------------------------------------------------------------------- + // Dialog save handlers + // ------------------------------------------------------------------- + + const handleSaveInitiative = useCallback( + async (form: InitiativeForm): Promise => { + if (pendingAdd?.kind !== "initiative") return; + const body: Record = { + name: form.name, + description: form.description || undefined, + status: form.status, + startDate: form.startDate || undefined, + targetDate: form.targetDate || undefined, + completedAt: form.completedAt || undefined, + }; + const res = await fetch(`/api/orgs/${githubLogin}/initiatives`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (!res.ok) { + // Surface enough info to console; the dialog's `onSave` swallows + // the throw so this is the user-visible signal we have today. + // A toast belongs here when we add the global toast system. + console.error( + "[OrgCanvasBackground] create initiative failed", + res.status, + ); + toast.error("Failed to create initiative"); + return; + } + const created: InitiativeResponse = await res.json(); + // Pin the new initiative to the click position before refetching + // so the refetched canvas already has the position overlay + // applied. If the position write fails, the projector falls back + // to default placement and the user can drag. + await savePositionForLiveId( + pendingAdd.canvasRef, + `initiative:${created.id}`, + pendingAdd.x, + pendingAdd.y, + ); + // Local refetch: don't wait for the Pusher fan-out (300ms delay + // + WebSocket round-trip + dirtyRef guard). The user just took + // an explicit action; the new card should appear immediately. + // Pusher still handles other tabs / users. + try { + const data = await fetchRoot(githubLogin); + setRoot(data); + } catch (err) { + console.error( + "[OrgCanvasBackground] refetch after create initiative failed", + err, + ); + } + }, + [githubLogin, pendingAdd, savePositionForLiveId], + ); + + const handleSaveMilestone = useCallback( + async (form: MilestoneForm): Promise<{ error?: string }> => { + if (pendingAdd?.kind !== "milestone") return {}; + const body: Record = { + name: form.name, + description: form.description || undefined, + status: form.status, + sequence: parseInt(form.sequence, 10), + dueDate: form.dueDate || undefined, + completedAt: form.completedAt || undefined, + }; + const res = await fetch( + `/api/orgs/${githubLogin}/initiatives/${pendingAdd.initiativeId}/milestones`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }, + ); + if (res.status === 409) { + return { + error: + "A milestone with that sequence already exists in this initiative.", + }; + } + if (!res.ok) { + return { error: "Failed to create milestone." }; + } + const created: MilestoneResponse = await res.json(); + // Same pattern as initiative create: position-save first so the + // refetch already reflects it, then refresh the timeline locally + // for snappy UX. Pusher still notifies other tabs. + const subRef = pendingAdd.canvasRef; + await savePositionForLiveId( + subRef, + `milestone:${created.id}`, + pendingAdd.x, + pendingAdd.y, + ); + try { + const data = await fetchSub(githubLogin, subRef); + setSubCanvases((prev) => ({ ...prev, [subRef]: data })); + } catch (err) { + console.error( + "[OrgCanvasBackground] refetch after create milestone failed", + err, + ); + } + return {}; + }, + [githubLogin, pendingAdd, savePositionForLiveId], + ); + + /** + * Save handler for the CreateFeatureCanvasDialog. Hits POST + * /api/features with the resolved `(workspaceId, initiativeId, + * milestoneId)` triple, then routes the click-position save and + * canvas refetch to the **target** canvas — which is the most + * specific scope the new feature lives on, NOT the canvas the user + * triggered from. Example: user creates from root and picks an + * initiative → the new feature card appears on the initiative's + * sub-canvas, so we save the position there and refetch that + * sub-canvas (not root). + * + * If the dialog was opened via "Promote to Feature" on a note, + * `pendingAdd.sourceNoteId` is set and we remove that note from the + * originating canvas after the feature is created — the user + * "consumed" the note when they promoted it. + */ + const handleSaveFeature = useCallback( + async (form: FeatureCreateForm): Promise => { + if (pendingAdd?.kind !== "feature") return; + const body: Record = { + title: form.title, + workspaceId: form.workspaceId, + ...(form.brief ? { brief: form.brief } : {}), + ...(form.initiativeId ? { initiativeId: form.initiativeId } : {}), + ...(form.milestoneId ? { milestoneId: form.milestoneId } : {}), + }; + const res = await fetch(`/api/features`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (!res.ok) { + // Surface enough info to console; toast lands here when the + // global toast system arrives (same gap as initiative/milestone). + const detail = await res.text().catch(() => ""); + console.error( + "[OrgCanvasBackground] create feature failed", + res.status, + detail, + ); + toast.error("Failed to create feature"); + return; + } + const payload = await res.json(); + const created: { id: string } = payload?.data ?? payload; + + // Resolve the target canvas ref by the "most specific place + // wins" rule. This must mirror the projector emission rules: + // - initiativeId set (with or without milestoneId) → initiative: + // - neither set → ws: + // Milestone-bound features render on their parent initiative's + // canvas (with a synthetic edge to the milestone card on the + // same canvas) — there is no separate milestone canvas. Mismatch + // here would save the position overlay on the wrong canvas, the + // projected node would render at the projector default, and the + // user's click position would silently vanish. + const targetRef: string = form.initiativeId + ? `initiative:${form.initiativeId}` + : `ws:${form.workspaceId}`; + + // Save the click position before refetching so the position + // overlay is already in the blob by the time the canvas reads. + // Note we pass the **target** ref, not the source canvas ref — + // the position lives on the canvas the feature renders on. + await savePositionForLiveId( + targetRef, + `feature:${created.id}`, + pendingAdd.x, + pendingAdd.y, + ); + + // Promote-from-note path: remove the source note. We do this + // through the same `applyMutation` flow as a regular delete so + // the autosave + Pusher broadcast follow the standard path. + if (pendingAdd.sourceNoteId) { + applyMutation(pendingAdd.canvasRef, (c) => + removeNode(c, pendingAdd.sourceNoteId!), + ); + } + + // Local refetch of the **target** canvas so the new feature + // shows up immediately. If the target is the same canvas the + // user is on, this is the natural refresh; if it's elsewhere + // (e.g. created on root, lands on initiative), the user will + // see the card on first drill-in. Pusher fan-out handles other + // tabs/users. + try { + if (targetRef === "") { + // Defensive: today no feature lands on root (the matrix + // above always picks a sub-canvas), but keep this branch + // so the code stays correct if the rules expand. + const data = await fetchRoot(githubLogin); + setRoot(data); + } else { + const data = await fetchSub(githubLogin, targetRef); + setSubCanvases((prev) => ({ ...prev, [targetRef]: data })); + } + } catch (err) { + console.error( + "[OrgCanvasBackground] refetch after create feature failed", + err, + ); + } + }, + [githubLogin, pendingAdd, savePositionForLiveId, applyMutation], + ); + + /** + * Save handler for `CreateServiceCanvasDialog`. Pure canvas-blob write + * — no REST POST, no projector. We take the lib-synthesized node we + * stashed in `pendingAdd.node`, merge in the dialog's `name` (as + * `text`) and `kind` (as `customData.kind`), and drop the result into + * the canvas blob via `applyMutation`. The click position the lib + * gave us is already on `node.x` / `node.y` so the card lands where + * the user clicked. + * + * No projector means no Pusher fan-out — the local `applyMutation` + * is the only state update needed. Other tabs/users see the new + * service on their next canvas read (or via autosave's mirror to + * `Canvas.data`). + */ + const handleSaveService = useCallback( + async (form: { name: string; kind: string }): Promise => { + if (pendingAdd?.kind !== "service") return; + const { node, canvasRef } = pendingAdd; + const merged: CanvasNode = { + ...node, + text: form.name, + customData: { + // Preserve anything the lib synthesized (today there's + // nothing, but defensive merge so future lib fields survive). + ...(node.customData ?? {}), + kind: form.kind, + }, + }; + applyMutation(canvasRef, (c) => addNode(c, merged)); + }, + [pendingAdd, applyMutation], + ); + + /** + * Save handler for the CreateFeatureCanvasDialog's **Assign existing** + * tab. Two flavors driven by the discriminated payload: + * + * - `kind: "workspace-pin"` (workspace canvas) — POST to + * `/canvas/assigned-features` with `action: "assign"` to add the + * feature id to the canvas's `assignedFeatures` overlay. Feature + * row unchanged. Click position lands the card. + * + * - `kind: "initiative-attach"` (initiative canvas) — PATCH + * `/api/features/[id]` with `{ initiativeId }` to set the loose + * feature's anchor. Service-side `notifyFeatureReassignmentRefresh` + * fans out CANVAS_UPDATED to both initiatives (in this case just + * the landing one — `before.initiativeId` is null for loose + * features). The projector emits the card on this initiative's + * canvas alongside its milestones on the next read. + * + * Both paths converge on the same `savePositionForLiveId + refetch` + * tail so the card lands where the user clicked. + */ + const handleAssignFeature = useCallback( + async (form: FeatureAssignForm): Promise => { + if (pendingAdd?.kind !== "feature") return; + + // Resolve `(targetRef, mutationFetch)` per flavor. `targetRef` + // is always the canvas the user is currently on — exactly the + // canvas the new card should appear on, since both flavors + // re-project onto the source scope after the mutation. + let targetRef: string; + let mutationFetch: () => Promise; + + if (form.kind === "workspace-pin") { + targetRef = `ws:${form.workspaceId}`; + // Defensive: the dialog only surfaces the workspace-pin + // payload when the pending scope is the matching workspace + // canvas. Bail out instead of silently writing to the wrong + // canvas if it doesn't. + if (pendingAdd.canvasRef !== targetRef) { + console.error( + "[OrgCanvasBackground] handleAssignFeature canvasRef mismatch (workspace)", + { pendingRef: pendingAdd.canvasRef, targetRef }, + ); + return; + } + mutationFetch = () => + fetch(`/api/orgs/${githubLogin}/canvas/assigned-features`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + ref: targetRef, + featureId: form.featureId, + action: "assign", + }), + }); + } else { + targetRef = `initiative:${form.initiativeId}`; + if (pendingAdd.canvasRef !== targetRef) { + console.error( + "[OrgCanvasBackground] handleAssignFeature canvasRef mismatch (initiative)", + { pendingRef: pendingAdd.canvasRef, targetRef }, + ); + return; + } + mutationFetch = () => + fetch(`/api/features/${form.featureId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ initiativeId: form.initiativeId }), + }); + } + + const res = await mutationFetch(); + if (!res.ok) { + const detail = await res.text().catch(() => ""); + console.error( + "[OrgCanvasBackground] assign feature failed", + form.kind, + res.status, + detail, + ); + toast.error("Failed to assign feature"); + return; + } + + await savePositionForLiveId( + targetRef, + `feature:${form.featureId}`, + pendingAdd.x, + pendingAdd.y, + ); + try { + const data = await fetchSub(githubLogin, targetRef); + setSubCanvases((prev) => ({ ...prev, [targetRef]: data })); + } catch (err) { + console.error( + "[OrgCanvasBackground] refetch after assign feature failed", + err, + ); + } + }, + [githubLogin, pendingAdd, savePositionForLiveId], + ); + + /** + * Persist a milestone status change to the DB. Fire-and-forget; on + * success the API route emits CANVAS_UPDATED which round-trips through + * the projector and reconciles the canonical status. On failure, log + * and let the next read pull the unchanged status — the optimistic + * local change will be quietly reverted, which is acceptable for the + * "I clicked the wrong swatch" scenario. + */ + const persistMilestoneStatus = useCallback( + async ( + milestoneId: string, + initiativeId: string, + status: "NOT_STARTED" | "IN_PROGRESS" | "COMPLETED", + ) => { + try { + const res = await fetch( + `/api/orgs/${githubLogin}/initiatives/${initiativeId}/milestones/${milestoneId}`, + { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ status }), + }, + ); + if (!res.ok) { + console.error( + `[OrgCanvasBackground] PATCH milestone status failed (${res.status})`, + ); + toast.error("Failed to update milestone status"); + } + } catch (err) { + console.error( + "[OrgCanvasBackground] PATCH milestone status threw", + err, + ); + toast.error("Failed to update milestone status"); + } + }, + [githubLogin], + ); + + const persistMilestoneName = useCallback( + async (milestoneId: string, initiativeId: string, name: string) => { + try { + const res = await fetch( + `/api/orgs/${githubLogin}/initiatives/${initiativeId}/milestones/${milestoneId}`, + { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name }), + }, + ); + if (!res.ok) { + const detail = await res.text().catch(() => ""); + console.error( + "[OrgCanvasBackground] PATCH milestone name failed", + res.status, + detail, + ); + toast.error("Failed to rename milestone"); + } + } catch (err) { + console.error( + "[OrgCanvasBackground] PATCH milestone name threw", + err, + ); + toast.error("Failed to rename milestone"); + } + }, + [githubLogin], + ); + + /** + * Persist a feature title rename to the DB. Same fire-and-forget + * shape as `persistMilestoneStatus`: optimistic local update lands + * via `applyMutation`, then the PATCH round-trips through the API + * route → projector → Pusher CANVAS_UPDATED, which reconciles to + * the canonical `Feature.title`. On failure, log and let the next + * read snap the card back to the prior title. + * + * Trim happens server-side too (`updateFeature`), but we trim here + * to skip the network call when the user typed only whitespace. + */ + const persistFeatureTitle = useCallback( + async (featureId: string, title: string) => { + const trimmed = title.trim(); + if (trimmed.length === 0) return; + try { + const res = await fetch(`/api/features/${featureId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ title: trimmed }), + }); + if (!res.ok) { + const detail = await res.text().catch(() => ""); + console.error( + "[OrgCanvasBackground] PATCH feature title failed", + res.status, + detail, + ); + toast.error("Failed to rename feature"); + } + } catch (err) { + console.error("[OrgCanvasBackground] PATCH feature title threw", err); + toast.error("Failed to rename feature"); + } + }, + [], + ); + + const handleNodeUpdate = useCallback( + (id: string, patch: NodeUpdate, canvasRef: string | undefined) => { + // Snapshot the pre-update node BEFORE we apply the mutation. The + // research-kickoff branch below needs to see whether the text + // just transitioned empty→non-empty, which requires the prior + // value. `applyMutation` reads from refs that lag React state + // by a render anyway, so reading them here is consistent with + // what the mutation will see. + const sourceCanvas = canvasRef + ? subCanvasesRef.current[canvasRef] + : rootRef.current; + const prevNode = sourceCanvas?.nodes?.find((n) => n.id === id); + + // Optimistic local update first — keeps the swatch reflecting the + // user's choice immediately, regardless of which path we take + // below. The autosave will silently drop customData on live ids + // (the splitter discards it), so this never round-trips through + // the canvas blob — the PATCH below is the only persistence path + // for status. + applyMutation(canvasRef, (c) => updateNode(c, id, patch)); + + // For milestone live nodes: a status change is a DB mutation, not + // a canvas blob change. Intercept it here and PATCH the milestone + // REST endpoint. Position changes (x/y) still flow through the + // normal autosave path as `positions[liveId]` overlays. + if ( + id.startsWith("milestone:") && + canvasRef?.startsWith("initiative:") + ) { + const newStatus = patch.customData?.status; + if ( + newStatus === "NOT_STARTED" || + newStatus === "IN_PROGRESS" || + newStatus === "COMPLETED" + ) { + const milestoneId = id.slice("milestone:".length); + const initiativeId = canvasRef.slice("initiative:".length); + void persistMilestoneStatus(milestoneId, initiativeId, newStatus); + } + } + + // For feature live nodes: a text edit is a DB title rename, not + // a canvas blob change. The splitter drops `text` on live ids + // (see `src/lib/canvas/io.ts`), so without this intercept the + // user's edit reverts on the next read. Skip when the text + // didn't actually change to avoid spurious PATCHes from + // re-renders. + if (id.startsWith("feature:") && patch.text !== undefined) { + const prevText = (prevNode?.text ?? "").trim(); + const nextText = patch.text.trim(); + if (nextText.length > 0 && nextText !== prevText) { + const featureId = id.slice("feature:".length); + void persistFeatureTitle(featureId, nextText); + } + } + + // For milestone live nodes: a text edit is a DB name rename, not + // a canvas blob change. The splitter drops `text` on live ids + // (see `src/lib/canvas/io.ts`), so without this intercept the + // user's edit reverts on the next read. Skip when the text + // didn't actually change to avoid spurious PATCHes. + if (id.startsWith("milestone:") && patch.text !== undefined) { + const prevText = (prevNode?.text ?? "").trim(); + const nextText = patch.text.trim(); + if ( + nextText.length > 0 && + nextText !== prevText && + canvasRef?.startsWith("initiative:") + ) { + const milestoneId = id.slice("milestone:".length); + const initiativeId = canvasRef.slice("initiative:".length); + void persistMilestoneName(milestoneId, initiativeId, nextText); + } + } + + // Research kickoff trigger. When an authored research node's + // text gets edited for the first time, fire the chat-side + // kickoff that drives `save_research`. Authored = no + // `research:` id prefix; that prefix only appears once the + // projector emits the live node post-save. The authored + // placeholder gets dropped automatically once the live node + // arrives — the dedupe runs in `dedupeAuthoredResearch` + // (`src/lib/canvas/io.ts`) on every read AND every write. + // + // The trigger isn't "empty→non-empty" because the library + // prefills new nodes with placeholder text like "New node" + // instead of "". Instead, we fire on the FIRST text edit per + // authored node id (tracked in `firedResearchKickoffsRef`), + // skipping spurious updates where text didn't actually change. + if ( + prevNode && + prevNode.category === "research" && + !id.startsWith("research:") && + patch.text !== undefined && + !firedResearchKickoffsRef.current.has(id) + ) { + const prevText = (prevNode.text ?? "").trim(); + const nextText = patch.text.trim(); + if (nextText.length > 0 && nextText !== prevText) { + firedResearchKickoffsRef.current.add(id); + fireResearchKickoff(nextText); + } + } + }, + [ + applyMutation, + fireResearchKickoff, + persistMilestoneStatus, + persistMilestoneName, + persistFeatureTitle, + ], + ); + + /** + * Batched drag commit. Fires once per drag-end with every moved node + * — for a single-node drag that's a one-entry array; for a group + * drag it's the group plus every spatially-contained child. + * + * Why this exists: `applyMutation` reads `rootRef.current` / + * `subCanvasesRef.current`, both mirrored from React state via + * `useEffect`. The mirror runs *after* React commits, so a synchronous + * loop of per-node `onNodeUpdate` calls all read the same stale + * starting state and each `setRoot` overwrites the previous one. The + * visible bug: dragging a group leaves every child snapping back + * except the last one in the iteration order. Folding all moves + * into a single `applyMutation` (which chains every patch through + * `updateNode` against one starting state) commits the group + * atomically. + * + * The drag path never carries `customData` — only x/y — so we can + * skip the milestone status special case here. Toolbar status + * changes still come through `onNodeUpdate` as before. + */ + const handleNodesUpdate = useCallback( + ( + updates: { id: string; patch: NodeUpdate }[], + canvasRef: string | undefined, + ) => { + if (updates.length === 0) return; + applyMutation(canvasRef, (c) => + updates.reduce((acc, u) => updateNode(acc, u.id, u.patch), c), + ); + }, + [applyMutation], + ); + + const handleNodeDelete = useCallback( + (id: string, canvasRef: string | undefined) => { + // Live nodes (workspaces, repos, features) aren't deletable — they + // belong to the DB. "Delete" on the canvas means "hide on this + // view." Route to the dedicated endpoint, and optimistically drop + // the node from local state so the UI reacts immediately. The + // endpoint is idempotent, so a race with a concurrent hide is + // harmless. + if (isLiveId(id)) { + applyMutation(canvasRef, (c) => removeNode(c, id)); + void toggleLiveVisibility(githubLogin, canvasRef, id, "hide").then( + () => { + if (!canvasRef) { + refreshRootHiddenLive(); + } else if (canvasRef === currentRefRef.current) { + refreshHiddenLive(); + } + }, + ); + return; + } + applyMutation(canvasRef, (c) => removeNode(c, id)); + }, + [applyMutation, githubLogin, refreshHiddenLive, refreshRootHiddenLive], + ); + + const handleNodesDelete = useCallback( + (ids: string[], canvasRef: string | undefined) => { + if (ids.length === 0) return; + + const liveIds = ids.filter(isLiveId); + + applyMutation(canvasRef, (c) => + ids.reduce((acc, id) => removeNode(acc, id), c), + ); + + for (const id of liveIds) { + void toggleLiveVisibility(githubLogin, canvasRef, id, "hide").then( + () => { + if (!canvasRef) { + refreshRootHiddenLive(); + } else if (canvasRef === currentRefRef.current) { + refreshHiddenLive(); + } + }, + ); + } + }, + [applyMutation, githubLogin, refreshHiddenLive, refreshRootHiddenLive], + ); + + return { + pendingAdd, + setPendingAdd, + startFeatureCreate, + handleNodeAdd, + handleNodeUpdate, + handleNodesUpdate, + handleNodeDelete, + handleNodesDelete, + handleSaveInitiative, + handleSaveMilestone, + handleSaveFeature, + handleSaveService, + handleAssignFeature, + }; +} diff --git a/src/app/org/[githubLogin]/connections/useCanvasPersistence.ts b/src/app/org/[githubLogin]/connections/useCanvasPersistence.ts new file mode 100644 index 0000000000..855885a4ea --- /dev/null +++ b/src/app/org/[githubLogin]/connections/useCanvasPersistence.ts @@ -0,0 +1,200 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import type { CanvasData } from "system-canvas-react"; + +const ROOT_KEY = "__root__"; +const AUTOSAVE_MS = 600; + +type DirtyMap = Map; + +async function fetchRoot(githubLogin: string): Promise { + const res = await fetch(`/api/orgs/${githubLogin}/canvas`); + if (!res.ok) throw new Error(`Failed to load canvas: ${res.status}`); + const body = await res.json(); + return (body.data ?? { nodes: [], edges: [] }) as CanvasData; +} + +async function fetchSub(githubLogin: string, ref: string): Promise { + const res = await fetch( + `/api/orgs/${githubLogin}/canvas/${encodeURIComponent(ref)}`, + ); + if (!res.ok) throw new Error(`Failed to load sub-canvas: ${res.status}`); + const body = await res.json(); + return (body.data ?? { nodes: [], edges: [] }) as CanvasData; +} + +async function saveCanvas( + githubLogin: string, + ref: string | undefined, + data: CanvasData, +): Promise { + const url = ref + ? `/api/orgs/${githubLogin}/canvas/${encodeURIComponent(ref)}` + : `/api/orgs/${githubLogin}/canvas`; + const res = await fetch(url, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ data }), + }); + if (!res.ok) { + console.error(`[useCanvasPersistence] PUT failed (${res.status})`); + } +} + +export { fetchRoot, fetchSub, saveCanvas }; + +interface UseCanvasPersistenceOptions { + githubLogin: string; +} + +interface UseCanvasPersistenceReturn { + root: CanvasData | null; + setRoot: React.Dispatch>; + subCanvases: Record; + setSubCanvases: React.Dispatch>>; + loadError: string | null; + retryLoad: () => void; + rootRef: React.RefObject; + subCanvasesRef: React.RefObject>; + dirtyRef: React.RefObject; + applyMutation: ( + canvasRef: string | undefined, + mutate: (data: CanvasData) => CanvasData, + ) => void; + onResolveCanvas: (ref: string) => Promise; +} + +export function useCanvasPersistence({ + githubLogin, +}: UseCanvasPersistenceOptions): UseCanvasPersistenceReturn { + const [root, setRoot] = useState(null); + const [subCanvases, setSubCanvases] = useState>( + {}, + ); + const [loadError, setLoadError] = useState(null); + + const subCanvasesRef = useRef(subCanvases); + useEffect(() => { + subCanvasesRef.current = subCanvases; + }, [subCanvases]); + const rootRef = useRef(root); + useEffect(() => { + rootRef.current = root; + }, [root]); + + const dirtyRef = useRef(new Map()); + const flushTimer = useRef | null>(null); + + const scheduleFlush = useCallback(() => { + if (flushTimer.current) clearTimeout(flushTimer.current); + flushTimer.current = setTimeout(() => { + const pending = dirtyRef.current; + dirtyRef.current = new Map(); + flushTimer.current = null; + for (const [key, data] of pending) { + const ref = key === ROOT_KEY ? undefined : key; + void saveCanvas(githubLogin, ref, data); + } + }, AUTOSAVE_MS); + }, [githubLogin]); + + const markDirty = useCallback( + (canvasRef: string | undefined, next: CanvasData) => { + dirtyRef.current.set(canvasRef ?? ROOT_KEY, next); + scheduleFlush(); + }, + [scheduleFlush], + ); + + // Initial root load. + useEffect(() => { + let cancelled = false; + fetchRoot(githubLogin) + .then((data) => { + if (!cancelled) setRoot(data); + }) + .catch((err) => { + if (!cancelled) { + console.error("[useCanvasPersistence] failed to load root", err); + setLoadError("Failed to load canvas"); + } + }); + return () => { + cancelled = true; + }; + }, [githubLogin]); + + // Flush pending saves on unmount. + useEffect(() => { + return () => { + if (flushTimer.current) { + clearTimeout(flushTimer.current); + flushTimer.current = null; + } + const pending = dirtyRef.current; + dirtyRef.current = new Map(); + for (const [key, data] of pending) { + const ref = key === ROOT_KEY ? undefined : key; + void saveCanvas(githubLogin, ref, data); + } + }; + }, [githubLogin]); + + const retryLoad = useCallback(() => { + setLoadError(null); + fetchRoot(githubLogin) + .then((data) => setRoot(data)) + .catch((err) => { + console.error("[useCanvasPersistence] retry load failed", err); + setLoadError("Failed to load canvas"); + }); + }, [githubLogin]); + + const applyMutation = useCallback( + ( + canvasRef: string | undefined, + mutate: (data: CanvasData) => CanvasData, + ) => { + if (!canvasRef) { + const current = rootRef.current; + if (!current) return; + const next = mutate(current); + setRoot(next); + markDirty(undefined, next); + return; + } + const current = subCanvasesRef.current[canvasRef]; + if (!current) return; + const next = mutate(current); + setSubCanvases((prev) => ({ ...prev, [canvasRef]: next })); + markDirty(canvasRef, next); + }, + [markDirty], + ); + + const onResolveCanvas = useCallback( + async (ref: string): Promise => { + const cached = subCanvasesRef.current[ref]; + if (cached) return cached; + const data = await fetchSub(githubLogin, ref); + setSubCanvases((prev) => ({ ...prev, [ref]: data })); + return data; + }, + [githubLogin], + ); + + return { + root, + setRoot, + subCanvases, + setSubCanvases, + loadError, + retryLoad, + rootRef, + subCanvasesRef, + dirtyRef, + applyMutation, + onResolveCanvas, + }; +}