Compare commits
2 Commits
d6b3d3c88b
...
bd44626532
| Author | SHA1 | Date | |
|---|---|---|---|
| bd44626532 | |||
| b0cdf64bbf |
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,13 +291,13 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- resizable browse | detail split -->
|
||||
<div class="flex min-h-0 flex-1">
|
||||
<div class="flex min-w-0 flex-1 flex-col">
|
||||
<!-- 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}
|
||||
selectedSlug={lastOpened}
|
||||
onSelect={select}
|
||||
bind:root={graphRoot}
|
||||
depth={graphDepth}
|
||||
@@ -329,40 +309,7 @@
|
||||
bind:info={graphInfo}
|
||||
/>
|
||||
{:else}
|
||||
<EntityTable entities={filteredEntities} loading={tableLoading} {selectedSlug} onSelect={select} {childToParent} />
|
||||
<EntityTable entities={filteredEntities} loading={tableLoading} selectedSlug={lastOpened} 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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
2
web/src/vite-env.d.ts
vendored
2
web/src/vite-env.d.ts
vendored
@@ -1,2 +1,4 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare const __OIKOS_VERSION__: string
|
||||
declare const __OIKOS_DEV_TOKEN__: string
|
||||
|
||||
Reference in New Issue
Block a user