From bd446265324a301a144e45663e76c96ca0739597 Mon Sep 17 00:00:00 2001 From: dtoro Date: Sat, 18 Jul 2026 10:46:27 +0200 Subject: [PATCH] feat(web): floating entity-detail windows (wmkit), replacing sidebar/sheet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- web/package-lock.json | 70 ++++++-- web/package.json | 1 + web/src/App.svelte | 3 + web/src/app.css | 39 ++++ web/src/lib/components/DetailSection.svelte | 4 +- web/src/lib/components/EntityDesktop.svelte | 46 +++++ .../lib/components/EntityDetailContent.svelte | 2 +- web/src/lib/components/EntitySheet.svelte | 28 --- web/src/lib/components/SessionGraph.svelte | 169 ++---------------- web/src/lib/stores/windows.ts | 24 +++ web/src/pages/EntityDetail.svelte | 3 +- web/src/pages/Knowledge.svelte | 13 +- web/src/pages/KnowledgeBase.svelte | 121 ++++--------- 13 files changed, 223 insertions(+), 300 deletions(-) create mode 100644 web/src/lib/components/EntityDesktop.svelte delete mode 100644 web/src/lib/components/EntitySheet.svelte create mode 100644 web/src/lib/stores/windows.ts diff --git a/web/package-lock.json b/web/package-lock.json index f4ad82e..fa44f4d 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -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" } } diff --git a/web/package.json b/web/package.json index 86490e4..435a850 100644 --- a/web/package.json +++ b/web/package.json @@ -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", diff --git a/web/src/App.svelte b/web/src/App.svelte index 3836591..9b3b40e 100644 --- a/web/src/App.svelte +++ b/web/src/App.svelte @@ -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 @@ + + {/if} diff --git a/web/src/app.css b/web/src/app.css index 0c5eb9b..16090f2 100644 --- a/web/src/app.css +++ b/web/src/app.css @@ -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; diff --git a/web/src/lib/components/DetailSection.svelte b/web/src/lib/components/DetailSection.svelte index 9009be3..346c25c 100644 --- a/web/src/lib/components/DetailSection.svelte +++ b/web/src/lib/components/DetailSection.svelte @@ -22,7 +22,7 @@ - + {title}{count !== undefined ? ` (${count})` : ''} -
+
{@render children()}
diff --git a/web/src/lib/components/EntityDesktop.svelte b/web/src/lib/components/EntityDesktop.svelte new file mode 100644 index 0000000..617b2de --- /dev/null +++ b/web/src/lib/components/EntityDesktop.svelte @@ -0,0 +1,46 @@ + + +
+ {#each $wmState.order as id (id)} + {@const win = $wmState.windows[id]} + {#if win} +
+
+ {win.title} +
+ + +
+
+
+ {#key id} + + {/key} +
+
+ {/if} + {/each} +
diff --git a/web/src/lib/components/EntityDetailContent.svelte b/web/src/lib/components/EntityDetailContent.svelte index 6aa5464..6a29358 100644 --- a/web/src/lib/components/EntityDetailContent.svelte +++ b/web/src/lib/components/EntityDetailContent.svelte @@ -244,7 +244,7 @@ } -
+
{#if loading} diff --git a/web/src/lib/components/EntitySheet.svelte b/web/src/lib/components/EntitySheet.svelte deleted file mode 100644 index 31d302b..0000000 --- a/web/src/lib/components/EntitySheet.svelte +++ /dev/null @@ -1,28 +0,0 @@ - - - - - - {currentSlug ?? 'Entity detail'} - Entity detail panel - - {#if currentSlug} - {#key currentSlug} - (currentSlug = s)} /> - {/key} - {/if} - - diff --git a/web/src/lib/components/SessionGraph.svelte b/web/src/lib/components/SessionGraph.svelte index 106c033..fb7973c 100644 --- a/web/src/lib/components/SessionGraph.svelte +++ b/web/src/lib/components/SessionGraph.svelte @@ -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([]) let links = $state([]) let selected = $state(null) - let sheetSlug = $state(null) - let sheetOpen = $state(false) let sim: Simulation | 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(null) - let asideHeight = $state(300) - function collectSlugs(value: unknown, out: Set) { 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 - } -