feat(web): floating entity-detail windows (wmkit), replacing sidebar/sheet
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
ci / web (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled

Every place that showed entity detail (Knowledge Base's right sidebar,
the EntitySheet drawer used by Knowledge and the chat session graph,
the standalone /entity/:slug page) now opens the entity in its own
floating, draggable, resizable window instead — several can be open
side by side, and clicking a relation inside one opens another,
building up a stack. Windows are managed by one global wmkit instance
(new $lib/stores/windows.ts + $lib/components/EntityDesktop.svelte,
mounted once in App.svelte), themed with the app's own card/border/ring
tokens rather than wmkit's bundled themes (app.css).

- Delete EntitySheet.svelte (redundant) and the KnowledgeBase resizable
  detail pane; row/graph-node click handlers now call
  openEntityWindow(slug) instead of setting local sidebar state.
- SessionGraph (chat's "Scope" mini-graph): clicking a node opens its
  window directly instead of a click-through mini-detail panel with
  its own resize handle and "Full detail" button — that whole
  subsystem is now dead and removed. Node highlight ring is kept
  (still useful to see what you last opened) and now clears itself via
  an effect watching the shared window-manager store, so closing a
  window drops the highlight instead of leaving it pointing at nothing
  — same fix applied to Knowledge Base's row highlight.
- Compact the entity-detail panel's padding (container + each
  DetailSection) now that it's typically viewed in a small window
  rather than a full-height sidebar.
- Fix KnowledgeBase's browse pane losing its flex-1/min-w-0 (and thus
  full width) when the wrapping single-child div around it was removed
  along with the old detail-pane split.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-18 10:46:27 +02:00
parent b0cdf64bbf
commit bd44626532
13 changed files with 223 additions and 300 deletions

View File

@@ -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>