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

@@ -22,7 +22,7 @@
</script>
<Collapsible.Root bind:open class="rounded-md border bg-card">
<Collapsible.Trigger class="flex w-full cursor-pointer select-none items-center justify-between gap-2 px-2.5 py-1.5 text-left hover:bg-muted/50">
<Collapsible.Trigger class="flex w-full cursor-pointer select-none items-center justify-between gap-2 px-2 py-1 text-left hover:bg-muted/50">
<span class="text-xs font-medium">{title}{count !== undefined ? ` (${count})` : ''}</span>
<ChevronDownIcon
class="size-3.5 shrink-0 text-muted-foreground transition-transform duration-200 {open ? 'rotate-180' : ''}"
@@ -30,7 +30,7 @@
/>
</Collapsible.Trigger>
<Collapsible.Content class="overflow-hidden data-[state=closed]:animate-out data-[state=closed]:fade-out data-[state=open]:animate-in data-[state=open]:fade-in">
<div class="border-t px-2.5 py-2">
<div class="border-t px-2 py-1.5">
{@render children()}
</div>
</Collapsible.Content>

View File

@@ -0,0 +1,46 @@
<script lang="ts">
// Global floating-window layer — mounted once in App.svelte, above every
// page, so an entity opened from Knowledge Base, chat, or anywhere else
// lands in the same window stack instead of each page owning its own
// single-entity sidebar/sheet. See $lib/stores/windows.ts.
import { dk, wmState, openEntityWindow } from '$lib/stores/windows'
import EntityDetailContent from './EntityDetailContent.svelte'
import XIcon from '@lucide/svelte/icons/x'
import MinusIcon from '@lucide/svelte/icons/minus'
</script>
<div use:dk.desktop class="pointer-events-none fixed inset-0 z-40">
{#each $wmState.order as id (id)}
{@const win = $wmState.windows[id]}
{#if win}
<section use:dk.window={{ id }} class="min-w-0" aria-label={win.title}>
<header data-wm-drag class="flex shrink-0 cursor-move items-center justify-between gap-2 border-b bg-muted/40 px-3 py-1.5">
<span data-wm-title class="min-w-0 flex-1 truncate font-mono text-xs font-medium">{win.title}</span>
<div class="flex shrink-0 items-center gap-0.5">
<button
type="button"
data-wm-minimize
class="rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
aria-label="Minimize {win.title}"
>
<MinusIcon class="size-3.5" />
</button>
<button
type="button"
data-wm-close
class="rounded p-1 text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
aria-label="Close {win.title}"
>
<XIcon class="size-3.5" />
</button>
</div>
</header>
<div data-wm-content class="min-h-0 flex-1 overflow-hidden">
{#key id}
<EntityDetailContent slug={id} onSelectEntity={openEntityWindow} />
{/key}
</div>
</section>
{/if}
{/each}
</div>

View File

@@ -244,7 +244,7 @@
}
</script>
<div class="flex h-full flex-col gap-2 overflow-y-auto p-3 md:p-4">
<div class="flex h-full flex-col gap-1.5 overflow-y-auto p-2">
{#if loading}
<Skeleton class="h-6 w-48" />
<Skeleton class="h-8 w-full" />

View File

@@ -1,28 +0,0 @@
<script lang="ts">
import EntityDetailContent from '$lib/components/EntityDetailContent.svelte'
import * as Sheet from '$lib/components/ui/sheet'
let { slug, open = $bindable(false) }: { slug: string | null; open?: boolean } = $props()
// Lets a relation click inside the sheet drill into that entity in place,
// without closing/reopening. Resets to the externally-requested slug
// whenever the caller opens the sheet on a different entity.
let currentSlug = $state<string | null>(null)
$effect(() => {
currentSlug = slug
})
</script>
<Sheet.Root bind:open>
<Sheet.Content side="right" class="w-full p-0 sm:max-w-2xl">
<Sheet.Header class="sr-only">
<Sheet.Title>{currentSlug ?? 'Entity detail'}</Sheet.Title>
<Sheet.Description>Entity detail panel</Sheet.Description>
</Sheet.Header>
{#if currentSlug}
{#key currentSlug}
<EntityDetailContent slug={currentSlug} onSelectEntity={(s) => (currentSlug = s)} />
{/key}
{/if}
</Sheet.Content>
</Sheet.Root>

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} />