From 4bd048c8fc6a001665452cf99d0a83b2ef264c6c Mon Sep 17 00:00:00 2001 From: xarmian Date: Mon, 8 Jun 2026 18:42:33 -0400 Subject: [PATCH] feat(web): item-header Graph button + dependency-graph drawer (TASK-1784) (#721) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire the 2D ItemGraph renderer into the item detail page: - New "πŸ•Έ Graph" action button in the item header (shown when the item has an issue ref) opens a right-docked drawer overlay (full-screen on narrow viewports) hosting ItemGraph, focused on the current item. - The renderer is dynamically imported on first open, so it (and its dagre layout lib) stay out of the item-page bundle. - Backdrop click, Close button, and Escape all dismiss; the ESC listener is only attached while the drawer is open. - Clicking a node's open action navigates to that item, building the /{user}/{ws}/{collection}/{ref} URL β€” ItemGraph.onOpenItem now passes the node's collection so the URL is correct without a lookup. Parent: PLAN-1780. --- web/src/lib/components/graph/ItemGraph.svelte | 12 +- .../[collection]/[slug]/+page.svelte | 137 ++++++++++++++++++ 2 files changed, 146 insertions(+), 3 deletions(-) diff --git a/web/src/lib/components/graph/ItemGraph.svelte b/web/src/lib/components/graph/ItemGraph.svelte index 7eedf633..ab7dae85 100644 --- a/web/src/lib/components/graph/ItemGraph.svelte +++ b/web/src/lib/components/graph/ItemGraph.svelte @@ -26,7 +26,9 @@ workspace: string; focusRef: string; depth?: number; - onOpenItem?: (ref: string) => void; + /** Called to open an item β€” collection is provided so callers can build + * a /{user}/{ws}/{collection}/{ref} URL without a lookup. */ + onOpenItem?: (ref: string, collection?: string) => void; } = $props(); // ── Fixed layout geometry ──────────────────────────────────────────────────── @@ -327,16 +329,20 @@ } // ── Interactions ────────────────────────────────────────────────────────────── + function collectionFor(ref: string): string | undefined { + return renderNodes.find((n) => n.ref === ref)?.collection; + } + function onNodeClick(ref: string) { if (ref === currentFocus) { - onOpenItem?.(ref); + onOpenItem?.(ref, collectionFor(ref)); } else { currentFocus = ref; } } function openFocused() { - onOpenItem?.(currentFocus); + onOpenItem?.(currentFocus, collectionFor(currentFocus)); } function backToOrigin() { diff --git a/web/src/routes/[username]/[workspace]/[collection]/[slug]/+page.svelte b/web/src/routes/[username]/[workspace]/[collection]/[slug]/+page.svelte index ea463f7b..b5a9360f 100644 --- a/web/src/routes/[username]/[workspace]/[collection]/[slug]/+page.svelte +++ b/web/src/routes/[username]/[workspace]/[collection]/[slug]/+page.svelte @@ -131,6 +131,42 @@ let workspaceMembers = $state<{ user_id: string; user_name: string; user_email: string; role: string }[]>([]); let shareDialogOpen = $state(false); let editCollectionOpen = $state(false); + + // Per-item dependency graph drawer (PLAN-1780 / TASK-1784). The renderer + // (and its dagre layout lib) are dynamically imported on first open so they + // stay out of the item-page bundle. + let showGraph = $state(false); + // The graph focuses by issue ref (e.g. TASK-5); null when the item has no + // number (shouldn't happen, but gates the button so we never focus on ''). + let graphFocusRef = $derived(item ? formatItemRef(item) : null); + let ItemGraphComp = $state< + typeof import('$lib/components/graph/ItemGraph.svelte').default | null + >(null); + let graphLoadError = $state(false); + async function openGraph() { + graphLoadError = false; + showGraph = true; + if (!ItemGraphComp) { + try { + ItemGraphComp = (await import('$lib/components/graph/ItemGraph.svelte')).default; + } catch { + graphLoadError = true; + } + } + } + function openItemFromGraph(ref: string, collection?: string) { + showGraph = false; + goto(`/${username}/${wsSlug}/${collection ?? collSlug}/${ref}`); + } + // ESC closes the graph drawer (only while open β€” no global listener otherwise). + $effect(() => { + if (!showGraph) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') showGraph = false; + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }); let editCollectionSection = $state<'general' | 'fields' | 'display' | 'actions' | undefined>(undefined); let agentRoles = $state([]); let childItemIds = $state>(new Set()); @@ -2328,6 +2364,15 @@ > Timeline + {#if graphFocusRef} + + {/if} {#if backlinksCount > 0} +
(showGraph = false)} + onkeydown={(e) => { + if (e.key === 'Enter' || e.key === ' ') showGraph = false; + }} + >
+ + {/if} + {#if isOwner && collection}