feat(web): floating entity-detail windows (wmkit), replacing sidebar/sheet
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:
70
web/package-lock.json
generated
70
web/package-lock.json
generated
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
46
web/src/lib/components/EntityDesktop.svelte
Normal file
46
web/src/lib/components/EntityDesktop.svelte
Normal 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>
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
@@ -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} />
|
||||
|
||||
24
web/src/lib/stores/windows.ts
Normal file
24
web/src/lib/stores/windows.ts
Normal 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 })
|
||||
}
|
||||
@@ -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} />
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user