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

70
web/package-lock.json generated
View File

@@ -8,6 +8,7 @@
"name": "oikos-web",
"version": "0.1.0",
"dependencies": {
"@surdeddd/wmkit": "^0.3.0",
"clsx": "^2.1.1",
"d3-force": "^3.0.0",
"dompurify": "^3.4.11",
@@ -876,7 +877,7 @@
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.0",
@@ -887,7 +888,7 @@
"version": "2.3.5",
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.5",
@@ -898,7 +899,7 @@
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
"dev": true,
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">=6.0.0"
@@ -908,14 +909,14 @@
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.31",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.1.0",
@@ -1282,11 +1283,44 @@
"win32"
]
},
"node_modules/@surdeddd/wmkit": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@surdeddd/wmkit/-/wmkit-0.3.0.tgz",
"integrity": "sha512-r5reUXN0Mcnx1nFjfB2grPCqpkH67S7UdaIY1bhyls5Tim4RyAAVscQSsimo1fOSqndZs9venjh3bueLJQVEMA==",
"license": "MIT",
"engines": {
"node": ">=20"
},
"peerDependencies": {
"@angular/core": ">=16",
"react": ">=18",
"solid-js": ">=1.8",
"svelte": ">=4",
"vue": ">=3.3"
},
"peerDependenciesMeta": {
"@angular/core": {
"optional": true
},
"react": {
"optional": true
},
"solid-js": {
"optional": true
},
"svelte": {
"optional": true
},
"vue": {
"optional": true
}
}
},
"node_modules/@sveltejs/acorn-typescript": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.11.tgz",
"integrity": "sha512-LFuZUkjJ9iF7JZye/aG5XM0SFcQ5VyL0oVX4WJ9dc0Va3R3s0OauX1BESVCb+YN/ol8TAfqGDDAQsTG627Y5kw==",
"dev": true,
"devOptional": true,
"license": "MIT",
"peerDependencies": {
"acorn": "^8.9.0"
@@ -1643,7 +1677,7 @@
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
"integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/@types/json-schema": {
@@ -2032,7 +2066,7 @@
"version": "8.17.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz",
"integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==",
"dev": true,
"devOptional": true,
"license": "MIT",
"bin": {
"acorn": "bin/acorn"
@@ -2105,7 +2139,7 @@
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz",
"integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==",
"dev": true,
"devOptional": true,
"license": "Apache-2.0",
"engines": {
"node": ">= 0.4"
@@ -2132,7 +2166,7 @@
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
"integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==",
"dev": true,
"devOptional": true,
"license": "Apache-2.0",
"engines": {
"node": ">= 0.4"
@@ -2514,7 +2548,7 @@
"version": "5.8.1",
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.1.tgz",
"integrity": "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/dompurify": {
@@ -2824,7 +2858,7 @@
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz",
"integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/espree": {
@@ -2862,7 +2896,7 @@
"version": "2.2.13",
"resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.13.tgz",
"integrity": "sha512-m8jH5hZgJE2RRUK/jjkGPcJEDAV+dYnZYFkosQaPTcE+Yw4xynXHOo6FUdwaWBtdR3b1MMa7wEDTSHeR2VWsGA==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.4.15"
@@ -3330,7 +3364,7 @@
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
"integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.6"
@@ -3754,7 +3788,7 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
"integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/locate-path": {
@@ -3808,7 +3842,7 @@
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.5"
@@ -4470,7 +4504,7 @@
"version": "5.56.4",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.4.tgz",
"integrity": "sha512-/d0QHehmRuJW8gVz395MTkPcPozxzdjBMBE8oEYGz8O3b9KTMzzQ9ZHJQLuFKOHOPQbU6kx/X4iid/EBBzH7iw==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"@jridgewell/remapping": "^2.3.4",
@@ -6281,7 +6315,7 @@
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz",
"integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==",
"dev": true,
"devOptional": true,
"license": "MIT"
}
}

View File

@@ -40,6 +40,7 @@
"vitest": "^2.0.0"
},
"dependencies": {
"@surdeddd/wmkit": "^0.3.0",
"clsx": "^2.1.1",
"d3-force": "^3.0.0",
"dompurify": "^3.4.11",

View File

@@ -8,6 +8,7 @@
import Knowledge from './pages/Knowledge.svelte'
import Learning from './pages/Learning.svelte'
import Config from './pages/Config.svelte'
import EntityDesktop from '$lib/components/EntityDesktop.svelte'
import { newChat } from '$lib/stores/chat'
import { summary, subscribeContext, openSignalCount } from '$lib/stores/context'
import { currentTask } from '$lib/stores/workspace'
@@ -249,4 +250,6 @@
</Sheet.Content>
</Sheet.Root>
<EntityDesktop />
{/if}

View File

@@ -242,6 +242,45 @@
height: 100%;
}
/* wmkit floating windows (EntityDesktop.svelte) — mapped onto the app's own
card/border/ring tokens instead of an imported wmkit theme, so windows
follow the terracotta/dark theme toggle for free. */
[data-wm-desktop] {
overflow: visible;
}
[data-wm-window] {
display: flex;
flex-direction: column;
box-sizing: border-box;
pointer-events: auto;
background: var(--card);
color: var(--card-foreground);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: 0 8px 24px oklch(0 0 0 / 0.18);
overflow: hidden;
outline: none;
}
[data-wm-window][data-wm-focused] {
border-color: var(--ring);
box-shadow: 0 12px 32px oklch(0 0 0 / 0.28);
}
[data-wm-window][data-wm-dragging],
[data-wm-window][data-wm-resizing] {
user-select: none;
}
[data-wm-window][data-wm-stage='minimized'] {
display: none;
}
[data-wm-resize] {
position: absolute;
}
a {
color: var(--accent-blue);
text-decoration: none;

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

View File

@@ -0,0 +1,24 @@
// One global wmkit window manager for the whole app (mounted once by
// EntityDesktop.svelte in App.svelte) — this is what lets an entity opened
// from Knowledge Base, chat, or anywhere else land in the same floating
// window layer, with several windows open side by side, rather than each
// page owning its own single-entity sidebar/sheet.
import { createManager, createDesktop, wmStore } from '@surdeddd/wmkit/svelte'
export const wm = createManager({ defaultSize: { width: 480, height: 560 } })
export const dk = createDesktop(wm)
export const wmState = wmStore(wm)
// Opens a window for the entity, or focuses (and restores, if minimized) the
// existing one — wm.open() throws if a window with this id already exists,
// and slugs make natural, stable window ids (also dedupes "same entity
// opened twice" into one window instead of stacking duplicates).
export function openEntityWindow(slug: string | null): void {
if (!slug) return
if (wm.get(slug)) {
wm.restore(slug)
wm.focus(slug)
return
}
wm.open({ id: slug, title: slug })
}

View File

@@ -1,7 +1,8 @@
<script lang="ts">
import EntityDetailContent from '$lib/components/EntityDetailContent.svelte'
import { openEntityWindow } from '$lib/stores/windows'
let { slug }: { slug: string } = $props()
</script>
<EntityDetailContent {slug} />
<EntityDetailContent {slug} onSelectEntity={openEntityWindow} />

View File

@@ -6,7 +6,7 @@
import { Input } from '$lib/components/ui/input'
import { Button } from '$lib/components/ui/button'
import { ScrollArea } from '$lib/components/ui/scroll-area'
import EntitySheet from '$lib/components/EntitySheet.svelte'
import { openEntityWindow } from '$lib/stores/windows'
import SearchIcon from '@lucide/svelte/icons/search'
import SparklesIcon from '@lucide/svelte/icons/sparkles'
import BotIcon from '@lucide/svelte/icons/bot'
@@ -56,13 +56,6 @@
return `${Math.floor(s / 86400)}d ago`
}
let sheetOpen = $state(false)
let selectedSlug = $state<string | null>(null)
function openEntity(slug: string) {
selectedSlug = slug
sheetOpen = true
}
</script>
<div class="flex h-full flex-col gap-4 p-4 md:p-6">
@@ -131,7 +124,7 @@
{#if hit.linked_entities?.length}
<div class="mt-1 flex flex-wrap gap-1">
{#each hit.linked_entities as slug}
<button type="button" class="font-mono text-xs text-muted-foreground underline" onclick={() => openEntity(slug)}>{slug}</button>
<button type="button" class="font-mono text-xs text-muted-foreground underline" onclick={() => openEntityWindow(slug)}>{slug}</button>
{/each}
</div>
{/if}
@@ -182,5 +175,3 @@
</ScrollArea>
{/if}
</div>
<EntitySheet slug={selectedSlug} bind:open={sheetOpen} />

View File

@@ -4,9 +4,9 @@
import { liveEvents, subscribeEvents } from '$lib/stores/events'
import EntityTable from '$lib/components/EntityTable.svelte'
import EntityGraph, { type GraphInfo } from '$lib/components/EntityGraph.svelte'
import EntityDetailContent from '$lib/components/EntityDetailContent.svelte'
import MultiSelectFilter from '$lib/components/MultiSelectFilter.svelte'
import { categories, filtersForCategory, type Category } from '$lib/categories'
import { openEntityWindow, wmState } from '$lib/stores/windows'
import { Button } from '$lib/components/ui/button'
import { Input } from '$lib/components/ui/input'
import * as Select from '$lib/components/ui/select'
@@ -15,7 +15,6 @@
import NetworkIcon from '@lucide/svelte/icons/share-2'
import TableIcon from '@lucide/svelte/icons/table-2'
import LocateFixedIcon from '@lucide/svelte/icons/locate-fixed'
import XIcon from '@lucide/svelte/icons/x'
type View = 'graph' | 'table'
@@ -26,7 +25,10 @@
let category = $state<Category>('fleet')
let view = $state<View>(loadView())
let selectedSlug = $state<string | null>(null)
// Tracks only the most recently opened entity, for row/node highlight —
// actual detail viewing now happens in floating windows (EntityDesktop),
// which can have several entities open at once.
let lastOpened = $state<string | null>(null)
function setView(v: View) {
view = v
@@ -34,9 +36,17 @@
}
function select(slug: string | null) {
selectedSlug = slug
lastOpened = slug
openEntityWindow(slug)
}
// Drop the highlight once its window is closed (from EntityDesktop, not
// necessarily from here) rather than leaving a row/node marked "open" when
// it isn't anymore.
$effect(() => {
if (lastOpened && !$wmState.windows[lastOpened]) lastOpened = null
})
// ─── table data: fetched here (not inside EntityTable) so the search/type
// toolbar lives in the shared page toolbar instead of the resizable browse
// pane, where its width is at the mercy of the divider and it would
@@ -206,36 +216,6 @@
function relColorFor(type: string): string {
return graphInfo.relColors.get(type) ?? '#30363d'
}
// ─── resizable browse/detail split (pattern from Chat.svelte) ─────────
const DETAIL_MIN = 320
const DETAIL_MAX = 900
function loadDetailWidth(): number {
if (typeof localStorage === 'undefined') return 420
const v = Number(localStorage.getItem('oikos-kb-detail-width'))
return v >= DETAIL_MIN && v <= DETAIL_MAX ? v : 420
}
let detailWidth = $state(loadDetailWidth())
let resizing = $state(false)
function startResize(e: PointerEvent) {
e.preventDefault()
resizing = true
;(e.currentTarget as Element).setPointerCapture(e.pointerId)
const startX = e.clientX
const startW = detailWidth
function move(ev: PointerEvent) {
detailWidth = Math.min(DETAIL_MAX, Math.max(DETAIL_MIN, startW + (startX - ev.clientX)))
}
function up() {
resizing = false
if (typeof localStorage !== 'undefined') localStorage.setItem('oikos-kb-detail-width', String(detailWidth))
window.removeEventListener('pointermove', move)
window.removeEventListener('pointerup', up)
}
window.addEventListener('pointermove', move)
window.addEventListener('pointerup', up)
}
</script>
<div class="flex h-full flex-col gap-3 p-4">
@@ -311,58 +291,25 @@
</div>
{/if}
<!-- resizable browse | detail split -->
<div class="flex min-h-0 flex-1">
<div class="flex min-w-0 flex-1 flex-col">
{#if view === 'graph'}
<EntityGraph
{category}
{selectedSlug}
onSelect={select}
bind:root={graphRoot}
depth={graphDepth}
search={graphSearch}
reloadToken={graphReloadToken}
resetToken={graphResetToken}
bind:activeNodeTypes={graphActiveNodeTypes}
bind:activeRelTypes={graphActiveRelTypes}
bind:info={graphInfo}
/>
{:else}
<EntityTable entities={filteredEntities} loading={tableLoading} {selectedSlug} onSelect={select} {childToParent} />
{/if}
</div>
<button
type="button"
class="group/rz relative w-1.5 shrink-0 cursor-col-resize touch-none"
onpointerdown={startResize}
aria-label="Resize detail panel"
>
<span
class="absolute inset-y-0 left-1/2 w-px -translate-x-1/2 transition-colors {resizing
? 'bg-primary/60'
: 'bg-border group-hover/rz:bg-primary/50'}"
></span>
</button>
<div class="flex shrink-0 flex-col overflow-hidden rounded-lg border" style="width: {detailWidth}px">
{#if selectedSlug}
<div class="flex shrink-0 items-center justify-end border-b p-1">
<Button variant="ghost" size="icon" class="size-7" onclick={() => select(null)} aria-label="Close detail panel">
<XIcon class="size-4" />
</Button>
</div>
<div class="min-h-0 flex-1">
{#key selectedSlug}
<EntityDetailContent slug={selectedSlug} onSelectEntity={select} />
{/key}
</div>
{:else}
<div class="flex h-full items-center justify-center p-6 text-center text-sm text-muted-foreground">
Select an entity to see its detail.
</div>
{/if}
</div>
<!-- browse pane — selecting an entity opens it in a floating window
(EntityDesktop, mounted globally in App.svelte) instead of a sidebar. -->
<div class="flex min-h-0 min-w-0 flex-1 flex-col">
{#if view === 'graph'}
<EntityGraph
{category}
selectedSlug={lastOpened}
onSelect={select}
bind:root={graphRoot}
depth={graphDepth}
search={graphSearch}
reloadToken={graphReloadToken}
resetToken={graphResetToken}
bind:activeNodeTypes={graphActiveNodeTypes}
bind:activeRelTypes={graphActiveRelTypes}
bind:info={graphInfo}
/>
{:else}
<EntityTable entities={filteredEntities} loading={tableLoading} selectedSlug={lastOpened} onSelect={select} {childToParent} />
{/if}
</div>
</div>