feat(web): floating entity-detail windows (wmkit), replacing sidebar/sheet
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
ci / web (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled

Every place that showed entity detail (Knowledge Base's right sidebar,
the EntitySheet drawer used by Knowledge and the chat session graph,
the standalone /entity/:slug page) now opens the entity in its own
floating, draggable, resizable window instead — several can be open
side by side, and clicking a relation inside one opens another,
building up a stack. Windows are managed by one global wmkit instance
(new $lib/stores/windows.ts + $lib/components/EntityDesktop.svelte,
mounted once in App.svelte), themed with the app's own card/border/ring
tokens rather than wmkit's bundled themes (app.css).

- Delete EntitySheet.svelte (redundant) and the KnowledgeBase resizable
  detail pane; row/graph-node click handlers now call
  openEntityWindow(slug) instead of setting local sidebar state.
- SessionGraph (chat's "Scope" mini-graph): clicking a node opens its
  window directly instead of a click-through mini-detail panel with
  its own resize handle and "Full detail" button — that whole
  subsystem is now dead and removed. Node highlight ring is kept
  (still useful to see what you last opened) and now clears itself via
  an effect watching the shared window-manager store, so closing a
  window drops the highlight instead of leaving it pointing at nothing
  — same fix applied to Knowledge Base's row highlight.
- Compact the entity-detail panel's padding (container + each
  DetailSection) now that it's typically viewed in a small window
  rather than a full-height sidebar.
- Fix KnowledgeBase's browse pane losing its flex-1/min-w-0 (and thus
  full width) when the wrapping single-child div around it was removed
  along with the old detail-pane split.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-18 10:46:27 +02:00
parent b0cdf64bbf
commit bd44626532
13 changed files with 223 additions and 300 deletions

View File

@@ -13,12 +13,7 @@
import { fetchGraph, type Entity } from '$lib/api'
import { messages } from '$lib/stores/chat'
import { touched, healthDiffs } from '$lib/stores/workspace'
import { relativeTime } from '$lib/utils'
import { Badge } from '$lib/components/ui/badge'
import { Button } from '$lib/components/ui/button'
import EntitySheet from '$lib/components/EntitySheet.svelte'
import ExternalLinkIcon from '@lucide/svelte/icons/external-link'
import XIcon from '@lucide/svelte/icons/x'
import { openEntityWindow, wmState } from '$lib/stores/windows'
interface Node extends Entity {
x?: number
@@ -46,8 +41,6 @@
let nodes = $state<Node[]>([])
let links = $state<Edge[]>([])
let selected = $state<Node | null>(null)
let sheetSlug = $state<string | null>(null)
let sheetOpen = $state(false)
let sim: Simulation<Node, Edge> | null = null
@@ -66,14 +59,6 @@
let cw = $state(300)
let ch = $state(300)
// This component sits INSIDE one of TaskContextPanel's own resizable slots
// (Scope), so — unlike a top-level section — its total budget can change at
// any time from outside (dragging the outer Scope/Plan handle), including
// while the detail panel below is open. asideHeight tracks that live budget
// so detailHeight can self-clamp to it instead of trusting a one-time seed.
let asideEl = $state<HTMLElement | null>(null)
let asideHeight = $state(300)
function collectSlugs(value: unknown, out: Set<string>) {
if (typeof value === 'string') {
const m = value.match(SLUG_RE)
@@ -217,13 +202,12 @@
return () => ro.disconnect()
})
// The highlight ring/dim styling below is tied to the node whose window
// was last opened — once that window is closed (from EntityDesktop, not
// necessarily from here), the ring should go with it rather than pointing
// at a window that no longer exists.
$effect(() => {
if (!asideEl) return
const ro = new ResizeObserver((entries) => {
asideHeight = Math.max(entries[0].contentRect.height, 1)
})
ro.observe(asideEl)
return () => ro.disconnect()
if (selected && !$wmState.windows[selected.slug]) selected = null
})
onDestroy(() => sim?.stop())
@@ -267,52 +251,11 @@
return typeof end === 'object' ? end.slug : end
}
// ─── graph / detail resize ───────────────────────────────────────────
// Same drag handle, same feel as TaskContextPanel's Scope/Plan/Activity
// split — but the graph side stays flex-1 (always auto-fills whatever's
// left) rather than tracking its own pixel number. Only detailHeight is
// explicit, and it's continuously clamped against asideHeight (this
// component's actual live budget) rather than a value seeded once — so
// resizing the OUTER Scope section while the detail panel is open can't
// push this panel past its container the way a one-time seed could.
const MIN_GRAPH = 80
const MIN_DETAIL = 80
const HANDLE = 6
let detailHeight = $state(200)
let resizing = $state(false)
let resizeStartY = $state(0)
let resizeStartH = $state(0)
function maxDetailHeight(): number {
return Math.max(MIN_DETAIL, asideHeight - MIN_GRAPH - HANDLE)
}
$effect(() => {
const max = maxDetailHeight()
if (detailHeight > max) detailHeight = max
})
function onPointerDown(e: PointerEvent) {
e.preventDefault()
resizing = true
resizeStartY = e.clientY
resizeStartH = detailHeight
window.addEventListener('pointermove', onPointerMove)
window.addEventListener('pointerup', onPointerUp)
}
function onPointerMove(e: PointerEvent) {
if (!resizing) return
const dy = e.clientY - resizeStartY
detailHeight = Math.min(maxDetailHeight(), Math.max(MIN_DETAIL, resizeStartH - dy))
}
function onPointerUp() {
resizing = false
window.removeEventListener('pointermove', onPointerMove)
window.removeEventListener('pointerup', onPointerUp)
}
// ─── drag / select ───────────────────────────────────────────────────
// A click (pointerdown+up with no movement in between) opens the entity
// straight in its own floating window (EntityDesktop) instead of a
// click-through mini-panel — `selected` now only drives the highlight/dim
// styling below, so you can see at a glance which node you last opened.
let dragState: { node: Node; moved: boolean } | null = null
function toLocal(clientX: number, clientY: number) {
@@ -334,6 +277,10 @@
dragState.moved = true
nodes = [...nodes]
}
function selectAndOpen(node: Node) {
selected = node
openEntityWindow(node.slug)
}
function onUp() {
if (!dragState) return
const { node, moved } = dragState
@@ -341,15 +288,7 @@
node.fy = null
sim?.alphaTarget(0)
dragState = null
if (!moved) {
const wasNull = selected === null
const next = selected?.slug === node.slug ? null : node
// A reasonable starting size on first open — the clamp effect above
// keeps it honest against the live container size from here on, so
// this doesn't need to be exact.
if (next && wasNull) detailHeight = Math.min(maxDetailHeight(), Math.round(ch * 0.45))
selected = next
}
if (!moved) selectAndOpen(node)
}
const selectedRelations = $derived(
@@ -362,15 +301,9 @@
})
: []
)
function openFull() {
if (!selected) return
sheetSlug = selected.slug
sheetOpen = true
}
</script>
<aside bind:this={asideEl} class="flex h-full min-h-0 flex-col bg-card/40">
<aside class="flex h-full min-h-0 flex-col bg-card/40">
{#if nowTouching}
<div class="flex shrink-0 items-center gap-1.5 border-b bg-primary/5 px-3 py-1.5 text-[11px] text-primary">
<span class="size-1.5 animate-pulse rounded-full bg-primary"></span>
@@ -455,7 +388,7 @@
role="button"
tabindex="0"
onpointerdown={(e) => onNodeDown(e, node)}
onkeydown={(e) => e.key === 'Enter' && (selected = node)}
onkeydown={(e) => e.key === 'Enter' && selectAndOpen(node)}
>
{#if isSel}
<circle r={r + 5} fill={nodeColor(node)} opacity="0.25" />
@@ -500,72 +433,4 @@
</svg>
{/if}
</div>
{#if selected}
<!-- resize handle -->
<div
class="h-1.5 shrink-0 cursor-row-resize border-b hover:bg-primary/30 touch-none"
onpointerdown={onPointerDown}
role="separator"
aria-orientation="horizontal"
></div>
<div class="shrink-0 space-y-3 overflow-y-auto p-3 text-xs" style="height: {detailHeight}px">
<div class="flex flex-wrap items-center gap-1.5">
<span class="min-w-0 flex-1 truncate font-mono text-sm font-semibold">{selected.slug}</span>
<Badge variant="outline">{selected.type}</Badge>
{#if selected.state}<Badge variant="secondary">{selected.state}</Badge>{/if}
<button
type="button"
class="shrink-0 rounded p-0.5 text-muted-foreground hover:bg-muted hover:text-foreground"
onclick={() => (selected = null)}
aria-label="Close entity detail"
>
<XIcon class="size-3.5" />
</button>
</div>
{#if selected.health}
<div class="flex items-center gap-1.5 text-muted-foreground">
<span class="size-2 rounded-full" style="background: {nodeColor(selected)}"></span>
{selected.health} · checked {relativeTime(selected.last_check_at)}
</div>
{/if}
{#if selected.attributes && Object.keys(selected.attributes).length}
<div>
<p class="mb-1 font-medium text-muted-foreground">Attributes</p>
<dl class="flex flex-col gap-1">
{#each Object.entries(selected.attributes).slice(0, 6) as [key, value]}
<div class="flex items-start justify-between gap-3 border-b pb-1 last:border-0">
<dt class="shrink-0 font-mono text-muted-foreground">{key}</dt>
<dd class="min-w-0 flex-1 truncate text-right">{typeof value === 'object' ? JSON.stringify(value) : String(value)}</dd>
</div>
{/each}
</dl>
</div>
{/if}
{#if selectedRelations.length}
<div>
<p class="mb-1 font-medium text-muted-foreground">Relations ({selectedRelations.length})</p>
<div class="flex flex-col gap-1">
{#each selectedRelations as rel}
<div class="flex items-center gap-1 font-mono">
<span class="text-muted-foreground">{rel.dir} {rel.type}</span>
<button type="button" class="truncate hover:underline" onclick={() => { const n = nodes.find((x) => x.slug === rel.other); if (n) selected = n }}>
{rel.other}
</button>
</div>
{/each}
</div>
</div>
{/if}
<Button variant="outline" size="sm" class="w-full" onclick={openFull}>
<ExternalLinkIcon class="mr-1 size-3.5" />
Full detail
</Button>
</div>
{/if}
</aside>
<EntitySheet slug={sheetSlug} bind:open={sheetOpen} />