feat(web): item-header Graph button + dependency-graph drawer (TASK-1784) (#721)

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.
This commit is contained in:
xarmian
2026-06-08 18:42:33 -04:00
committed by GitHub
parent 771d102e89
commit 4bd048c8fc
2 changed files with 146 additions and 3 deletions
@@ -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() {
@@ -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<AgentRole[]>([]);
let childItemIds = $state<Set<string>>(new Set());
@@ -2328,6 +2364,15 @@
>
Timeline
</button>
{#if graphFocusRef}
<button
class="action-btn"
title="View this item's dependency graph"
onclick={openGraph}
>
🕸 Graph
</button>
{/if}
{#if backlinksCount > 0}
<!--
Mention badge (PLAN-1593 / TASK-1596). Surfaces the inbound
@@ -2926,6 +2971,39 @@
/>
{/if}
{#if showGraph && item && graphFocusRef}
<!-- Per-item dependency graph drawer (PLAN-1780 / TASK-1784). -->
<div
class="graph-drawer-backdrop"
role="button"
tabindex="-1"
aria-label="Close dependency graph"
onclick={() => (showGraph = false)}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') showGraph = false;
}}
></div>
<aside class="graph-drawer" aria-label="Dependency graph">
<header class="graph-drawer-header">
<span class="graph-drawer-title">🕸 {graphFocusRef} — dependency graph</span>
<button class="action-btn" onclick={() => (showGraph = false)}>Close ✕</button>
</header>
<div class="graph-drawer-body">
{#if graphLoadError}
<div class="graph-drawer-state">
<p>Couldnt load the graph view.</p>
<button class="action-btn" onclick={openGraph}>Retry</button>
</div>
{:else if ItemGraphComp}
{@const Graph = ItemGraphComp}
<Graph workspace={wsSlug} focusRef={graphFocusRef} onOpenItem={openItemFromGraph} />
{:else}
<div class="graph-drawer-state">Loading graph…</div>
{/if}
</div>
</aside>
{/if}
{#if isOwner && collection}
<EditCollectionModal
bind:open={editCollectionOpen}
@@ -3715,6 +3793,65 @@
border-color: var(--accent-blue);
color: #fff;
}
/* Per-item dependency graph drawer (PLAN-1780 / TASK-1784). Right-docked
panel with a dimming backdrop; on narrow viewports it fills the screen. */
.graph-drawer-backdrop {
position: fixed;
inset: 0;
background: color-mix(in srgb, #000 55%, transparent);
backdrop-filter: blur(2px);
z-index: 90;
border: 0;
cursor: pointer;
}
.graph-drawer {
position: fixed;
top: 0;
right: 0;
bottom: 0;
width: min(900px, 92vw);
display: flex;
flex-direction: column;
background: var(--bg-primary);
border-left: 1px solid var(--border);
box-shadow: -8px 0 32px rgba(0, 0, 0, 0.4);
z-index: 91;
}
.graph-drawer-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--border);
flex: 0 0 auto;
}
.graph-drawer-title {
font-weight: 600;
font-size: 0.95em;
color: var(--text-primary);
}
.graph-drawer-body {
flex: 1 1 auto;
min-height: 0;
position: relative;
}
.graph-drawer-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--space-3);
height: 100%;
color: var(--text-secondary);
}
@media (max-width: 640px) {
.graph-drawer {
width: 100vw;
border-left: 0;
}
}
.star-btn.starred {
color: var(--accent-amber);
}