3 Commits

Author SHA1 Message Date
8657ac5669 feat(web): open tasks/sessions as floating windows with independent live chat
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
Clicking a task now opens it as a wmkit floating window (like entity
windows already do) instead of navigating away from wherever you were.
Several task windows can be open and actively streaming at once, each
fully independent — no "which one's on screen" guard needed, since
each window owns its own store bundle:

- chat.ts: chatFor(sessionId)/loadSessionChat/sendSessionMessage give
  each window its own messages/streaming/connectionState, alongside
  the existing singleton path the main Chat page still uses unchanged.
- workspace.ts: same split for plan/questions/touched/health-diffs
  (workspaceFor/startSessionWorkspace), each with its own live-event
  watermark since several windows can watch the same event stream.
- activity.ts: activityLogFor(sessionId) mirrors the global derivation.

SessionGraph.svelte, OperatorQuestion.svelte, and ActivityTimeline.svelte
were converted from store-importing to prop-driven (matching the new
ChatThread.svelte, extracted from Chat.svelte's transcript/input so both
the main page and task windows share one implementation instead of
duplicating markup/styling) so each can render either the global
"current session" or a specific window's session.

Also: minimized-window taskbar chips now cap at a max width with
middle-ellipsis truncation instead of growing unbounded, and the
window header's title/action-button row is fixed to genuinely match
heights (not just share a center point) for more robust alignment.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-19 17:01:34 +02:00
e28e0e9ea3 feat(web): unify Knowledge Base filtering into one type multiselect
Replace the Fleet/Network/Identity/Knowledge category tabs (which
scoped entity fetches server-side) with a single "Types" multiselect
shared by both the table and graph views — both now fetch the whole
entity set (paginated via the new fetchAllEntities) and filter
client-side, defaulting to fleet's types. Table and graph also share
one search/highlight field instead of two separately-labeled ones.

Along the way, fixed a real bug the wider entity set exposed: the
treegrid's parent/child grouping fired one fetchGraph call per
candidate root entity, fine for the old ~50-entity fleet scope but an
ERR_INSUFFICIENT_RESOURCES flood once scoped to the full ~1700-entity
set. Replaced with a single whole-graph fetch, deriving parent/child
pairs from its edges client-side.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-19 17:01:34 +02:00
6051fb4845 feat(web): curved edges + unique SVG ids for concurrent graph views
Quadratic-bezier edges instead of straight lines, and drop the
auto-refit-on-load that caused a jarring zoom/pan snap once the force
simulation settled. Also namespace each graph's dot-grid pattern id
with a per-instance uuid — multiple SessionGraph instances can now be
mounted at once (one per open task window), and duplicate SVG ids
silently blanked out every graph's background but the first.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-19 17:01:34 +02:00
20 changed files with 1173 additions and 726 deletions

View File

@@ -9,6 +9,7 @@
import Learning from './pages/Learning.svelte' import Learning from './pages/Learning.svelte'
import Config from './pages/Config.svelte' import Config from './pages/Config.svelte'
import EntityDesktop from '$lib/components/EntityDesktop.svelte' import EntityDesktop from '$lib/components/EntityDesktop.svelte'
import MinimizedWindowsBar from '$lib/components/MinimizedWindowsBar.svelte'
import { newChat } from '$lib/stores/chat' import { newChat } from '$lib/stores/chat'
import { summary, subscribeContext, openSignalCount } from '$lib/stores/context' import { summary, subscribeContext, openSignalCount } from '$lib/stores/context'
import { currentTask } from '$lib/stores/workspace' import { currentTask } from '$lib/stores/workspace'
@@ -235,6 +236,7 @@
<Chat /> <Chat />
{/if} {/if}
</main> </main>
<MinimizedWindowsBar />
</Sidebar.Inset> </Sidebar.Inset>
</Sidebar.Provider> </Sidebar.Provider>

View File

@@ -213,6 +213,24 @@ export async function fetchEntities(filters: EntityFilters = {}): Promise<Entity
return data.items ?? [] return data.items ?? []
} }
// The entities endpoint caps at 200/page — the Knowledge Base wants the
// whole set (it filters by type/search client-side now instead of scoping
// the fetch server-side), so page through via cursor until exhausted.
export async function fetchAllEntities(): Promise<Entity[]> {
const all: Entity[] = []
let cursor: string | undefined
do {
const params = new URLSearchParams({ limit: '200' })
if (cursor) params.set('cursor', cursor)
const res = await fetchWithAuth(`${API}/entities?${params}`)
if (!res.ok) break
const data = await res.json()
all.push(...(data.items ?? []))
cursor = data.next_cursor ?? undefined
} while (cursor)
return all
}
export type OntologyLayer = 'meta' | 'infrastructure' | 'governance' | 'cognition' export type OntologyLayer = 'meta' | 'infrastructure' | 'governance' | 'cognition'
export interface EntityType { export interface EntityType {

View File

@@ -3,18 +3,11 @@
// which lumps very different things (an LXC and a DNS record and a storage // which lumps very different things (an LXC and a DNS record and a storage
// volume) into one "infrastructure" bucket. Built from the ontology's // volume) into one "infrastructure" bucket. Built from the ontology's
// `domain` field instead, which already draws these lines; this just // `domain` field instead, which already draws these lines; this just
// groups the domains into browsing-sized buckets. // groups the domains into browsing-sized buckets. The Knowledge Base shows
import type { EntityFilters } from './api' // every entity at once now (filtered by the type multiselect, not by a
// fetch-time category), but "fleet" still names the default type selection.
export type Category = 'network' | 'fleet' | 'identity' | 'knowledge' export type Category = 'network' | 'fleet' | 'identity' | 'knowledge'
export const categories: { id: Category; label: string }[] = [
{ id: 'fleet', label: 'Fleet' },
{ id: 'network', label: 'Network' },
{ id: 'identity', label: 'Identity' },
{ id: 'knowledge', label: 'Knowledge' }
]
// entity_types.domain -> Category. `external` folds into Network (isp-link, // entity_types.domain -> Category. `external` folds into Network (isp-link,
// domain-registration are network-adjacent); `physical`, `software`, and // domain-registration are network-adjacent); `physical`, `software`, and
// `storage` fold into Fleet (ups/sensor/site support compute, services/apps // `storage` fold into Fleet (ups/sensor/site support compute, services/apps
@@ -49,15 +42,3 @@ export function typeToCategory(type: string, domain: string): Category | undefin
if (domain === 'cognition') return undefined if (domain === 'cognition') return undefined
return DOMAIN_TO_CATEGORY[domain] return DOMAIN_TO_CATEGORY[domain]
} }
// Filter sets to fetch and merge for a category's table view. Most
// categories are one or two `domain` values; Knowledge is a handful of
// specific `type`s carved out of the (otherwise excluded) cognition domain.
export function filtersForCategory(category: Category): EntityFilters[] {
if (category === 'knowledge') {
return Array.from(KNOWLEDGE_TYPES).map((type) => ({ type }))
}
return Object.entries(DOMAIN_TO_CATEGORY)
.filter(([, c]) => c === category)
.map(([domain]) => ({ domain }))
}

View File

@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { activityLog, type ActivityEntry } from '$lib/stores/activity' import type { ActivityEntry } from '$lib/stores/activity'
import Spinner from './Spinner.svelte' import Spinner from './Spinner.svelte'
import CircleDotIcon from '@lucide/svelte/icons/circle-dot' import CircleDotIcon from '@lucide/svelte/icons/circle-dot'
import CircleXIcon from '@lucide/svelte/icons/circle-x' import CircleXIcon from '@lucide/svelte/icons/circle-x'
@@ -11,6 +11,9 @@
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down' import ChevronDownIcon from '@lucide/svelte/icons/chevron-down'
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right' import ChevronRightIcon from '@lucide/svelte/icons/chevron-right'
// Prop-driven (not store-imported) — see SessionGraph.svelte for why.
let { entries }: { entries: ActivityEntry[] } = $props()
let expanded = $state(new Set<string>()) let expanded = $state(new Set<string>())
function toggle(id: string) { function toggle(id: string) {
@@ -56,7 +59,7 @@
<div class="flex h-full flex-col"> <div class="flex h-full flex-col">
<div class="flex-1 overflow-y-auto"> <div class="flex-1 overflow-y-auto">
{#if $activityLog.length === 0} {#if entries.length === 0}
<div class="flex flex-col items-center gap-3 px-3 py-8 text-center"> <div class="flex flex-col items-center gap-3 px-3 py-8 text-center">
<svg viewBox="0 0 64 110" class="h-20 w-auto text-muted-foreground/40" fill="none"> <svg viewBox="0 0 64 110" class="h-20 w-auto text-muted-foreground/40" fill="none">
<line x1="32" y1="8" x2="32" y2="102" stroke="currentColor" stroke-width="1" stroke-dasharray="2.5 4" opacity="0.35" /> <line x1="32" y1="8" x2="32" y2="102" stroke="currentColor" stroke-width="1" stroke-dasharray="2.5 4" opacity="0.35" />
@@ -78,8 +81,8 @@
</div> </div>
{:else} {:else}
<div class="flex flex-col py-1"> <div class="flex flex-col py-1">
{#each $activityLog as entry, i (entry.id)} {#each entries as entry, i (entry.id)}
{@const isLast = i === $activityLog.length - 1} {@const isLast = i === entries.length - 1}
{@const icon = typeIcon(entry.type)} {@const icon = typeIcon(entry.type)}
{@const isOpen = expanded.has(entry.id)} {@const isOpen = expanded.has(entry.id)}
{@const time = formatTime(entry.timestamp)} {@const time = formatTime(entry.timestamp)}

View File

@@ -0,0 +1,377 @@
<script lang="ts">
// Pure prop-driven transcript + input — no store imports. Both the main
// Chat page (singleton "current session" stores) and a floating task
// window (its own per-session store bundle from chat.ts's chatFor) render
// through this, so the message-bubble/markdown styling lives in one place
// instead of being copy-pasted between the two.
import { activityLog } from '$lib/stores/activity'
import AgentIndicator from '$lib/components/AgentIndicator.svelte'
import { Button } from '$lib/components/ui/button'
import { Textarea } from '$lib/components/ui/textarea'
import ArrowUpIcon from '@lucide/svelte/icons/arrow-up'
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw'
import SquareIcon from '@lucide/svelte/icons/square'
import { marked } from 'marked'
import DOMPurify from 'dompurify'
import type { ChatMessage } from '$lib/stores/chat'
let {
messages,
streaming,
connectionState,
error = null,
chatErrors = [],
onSend,
onCancel,
onReconnect,
onDismissError,
suggestions = []
}: {
messages: ChatMessage[]
streaming: boolean
connectionState: 'connected' | 'disconnected' | 'reconnecting'
error?: string | null
chatErrors?: { id: string; message: string; action?: string }[]
onSend: (text: string) => void
onCancel: () => void
onReconnect: () => void
onDismissError: (id: string) => void
suggestions?: string[]
} = $props()
let input = $state('')
let messagesEnd = $state<HTMLDivElement | null>(null)
let scrolledUp = $state(false)
let container = $state<HTMLDivElement | null>(null)
function isNearBottom(): boolean {
if (!container) return true
const { scrollTop, scrollHeight, clientHeight } = container
return scrollHeight - scrollTop - clientHeight < 80
}
function onScroll() {
scrolledUp = !isNearBottom()
}
// Auto-scroll to bottom on new messages — unless user scrolled up to read.
$effect(() => {
void messages
if (streaming || !scrolledUp) {
setTimeout(() => messagesEnd?.scrollIntoView({ behavior: 'smooth' }), 50)
}
})
function render(text: string): string {
return DOMPurify.sanitize(marked.parse(text, { async: false }) as string)
}
function submit() {
const text = input.trim()
if (!text || streaming) return
input = ''
scrolledUp = false
onSend(text)
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
submit()
}
}
function ask(q: string) {
if (streaming) return
onSend(q)
}
</script>
<div class="flex min-h-0 min-w-0 flex-1 flex-col">
<div class="min-h-0 flex-1 overflow-y-auto" bind:this={container} onscroll={onScroll}>
<div class="mx-auto flex max-w-3xl flex-col gap-5 p-4">
{#if messages.length === 0}
<div class="flex flex-col items-center gap-6 pt-24 text-center">
<div>
<h2 class="text-xl font-semibold">Nomos</h2>
<p class="mt-1 text-sm text-muted-foreground">Your resident operator. Ask about the fleet, or tell it to act.</p>
</div>
{#if suggestions.length}
<div class="grid w-full max-w-md grid-cols-1 gap-2 sm:grid-cols-2">
{#each suggestions as q}
<Button variant="outline" size="sm" class="h-auto justify-start whitespace-normal py-2 text-left text-xs" onclick={() => ask(q)}>
{q}
</Button>
{/each}
</div>
{/if}
</div>
{/if}
{#each messages as msg (msg.id)}
<div class="flex flex-col gap-1.5 {msg.role === 'user' ? 'items-end' : 'items-start'}">
{#if msg.role === 'user'}
<div class="max-w-[85%] rounded-2xl rounded-br-sm bg-primary px-4 py-2.5 text-sm text-primary-foreground whitespace-pre-wrap user-msg">{msg.text}</div>
{:else}
<div class="flex w-full flex-col gap-2">
{#if msg.text}
<div class="prose-chat max-w-none text-sm leading-relaxed assistant-msg">
<!-- eslint-disable-next-line svelte/no-at-html-tags — sanitized via DOMPurify -->
{@html render(msg.text)}
</div>
{/if}
</div>
{/if}
</div>
{/each}
<AgentIndicator
active={streaming || $activityLog.some((e) => e.status === 'running')}
lastActivity={$activityLog.find((e) => e.status === 'running') ?? null}
{error}
/>
<div bind:this={messagesEnd}></div>
</div>
</div>
{#if connectionState === 'disconnected'}
<div class="mx-auto w-full max-w-3xl px-4">
<div class="mb-2 flex items-center gap-2 rounded-md border border-warning/50 bg-warning/10 px-3 py-2 text-xs">
<RefreshCwIcon class="size-3 shrink-0" aria-hidden="true" />
<span class="text-warning-foreground flex-1">Agent connection lost. The task may still be running.</span>
<Button size="xs" variant="outline" class="h-6 text-[11px]" onclick={onReconnect}>Reconnect</Button>
</div>
</div>
{:else if connectionState === 'reconnecting'}
<div class="mx-auto w-full max-w-3xl px-4">
<div class="mb-2 flex items-center gap-2 rounded-md border bg-muted/50 px-3 py-2 text-xs">
<RefreshCwIcon class="size-3 shrink-0 animate-spin text-muted-foreground" aria-hidden="true" />
<span class="text-muted-foreground flex-1">Reconnecting to agent…</span>
</div>
</div>
{/if}
{#if error}
<div class="mx-auto w-full max-w-3xl px-4">
<div class="mb-2 rounded-md border border-destructive/50 bg-destructive/10 px-3 py-2 text-xs text-destructive">
{error}
</div>
</div>
{/if}
{#each chatErrors as err (err.id)}
<div class="mx-auto w-full max-w-3xl px-4">
<div class="mb-2 flex items-center gap-2 rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs text-destructive">
<span class="flex-1">{err.message}</span>
{#if err.action}
<Button size="xs" variant="ghost" class="h-6 text-[11px]" onclick={() => onDismissError(err.id)}>{err.action}</Button>
{/if}
<button class="ml-1 text-muted-foreground hover:text-foreground" onclick={() => onDismissError(err.id)} aria-label="Dismiss">×</button>
</div>
</div>
{/each}
<div class="border-t bg-card/50 p-3 input-ornament relative">
<form
class="mx-auto flex max-w-3xl items-end gap-2"
onsubmit={(e) => {
e.preventDefault()
submit()
}}
>
<Textarea
bind:value={input}
onkeydown={handleKeydown}
placeholder="Ask Nomos anything…"
rows={2}
class="max-h-40 min-h-0 resize-none"
disabled={streaming}
/>
{#if streaming}
<Button type="button" size="icon" variant="destructive" onclick={onCancel} aria-label="Stop">
<SquareIcon />
</Button>
{:else}
<Button type="submit" size="icon" disabled={!input.trim()} aria-label="Send">
<ArrowUpIcon />
</Button>
{/if}
</form>
</div>
</div>
<style>
/* ── Art Nouveau chat styling ── */
/* Assistant message wrapper */
.assistant-msg {
position: relative;
}
/* User message — soft terracotta bubble, gentle lift */
.user-msg {
box-shadow: 0 1px 8px -4px var(--primary);
}
/* Prose overrides */
.prose-chat :global(p) {
margin: 0 0 0.5rem;
}
.prose-chat :global(p:last-child) {
margin-bottom: 0;
}
.prose-chat :global(ul),
.prose-chat :global(ol) {
margin: 0 0 0.5rem;
padding-left: 1.25rem;
}
.prose-chat :global(ul) {
list-style-type: disc;
}
.prose-chat :global(ol) {
list-style-type: decimal;
}
.prose-chat :global(li) {
margin-bottom: 0.125rem;
padding-left: 0.25rem;
}
.prose-chat :global(li::marker) {
color: var(--primary);
}
.prose-chat :global(code) {
background: var(--muted);
border: 1px solid var(--border);
border-radius: 4px;
padding: 0.15em 0.4em;
font-family: var(--font-mono);
font-size: 0.85em;
color: var(--primary);
}
.prose-chat :global(pre) {
background: var(--muted);
border: 1px solid var(--border);
border-radius: 8px;
padding: 0.75rem 0.875rem;
overflow-x: auto;
margin: 0 0 0.5rem;
position: relative;
}
.prose-chat :global(pre)::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 1px;
background: linear-gradient(to right, transparent, var(--primary), transparent);
opacity: 0.4;
}
.prose-chat :global(pre code) {
background: none;
padding: 0;
font-size: 0.8125rem;
color: inherit;
border: none;
}
/* Section headings — serif (Inknut) with a short accent rule. Extra top
margin separates sections; the first heading in a message doesn't. */
.prose-chat :global(h1),
.prose-chat :global(h2),
.prose-chat :global(h3) {
font-weight: 600;
margin: 1.15rem 0 0.4rem;
font-size: 1.03em;
letter-spacing: 0.01em;
position: relative;
display: inline-block;
}
.prose-chat :global(> h1:first-child),
.prose-chat :global(> h2:first-child),
.prose-chat :global(> h3:first-child) {
margin-top: 0;
}
.prose-chat :global(h1)::after,
.prose-chat :global(h2)::after,
.prose-chat :global(h3)::after {
content: '';
display: block;
width: 2.5rem;
height: 2px;
margin-top: 4px;
border-radius: 1px;
background: linear-gradient(to right, var(--primary), transparent);
opacity: 0.55;
}
.prose-chat :global(table) {
border-collapse: collapse;
margin: 0 0 0.5rem;
font-size: 0.8125rem;
}
.prose-chat :global(th) {
background: var(--muted);
font-weight: 600;
}
.prose-chat :global(th),
.prose-chat :global(td) {
border: 1px solid var(--border);
padding: 0.3rem 0.6rem;
text-align: left;
}
.prose-chat :global(blockquote) {
border-left: 3px solid var(--primary);
padding-left: 0.75rem;
color: var(--muted-foreground);
margin: 0 0 0.5rem;
font-style: italic;
position: relative;
}
.prose-chat :global(blockquote)::before {
content: '“';
position: absolute;
left: -0.15rem;
top: -0.35rem;
font-size: 1.5rem;
color: var(--primary);
opacity: 0.6;
font-style: normal;
line-height: 1;
}
.prose-chat :global(hr) {
border: none;
height: 1px;
margin: 0.75rem 0;
background: linear-gradient(to right, transparent, var(--border) 20%, var(--border) 80%, transparent);
}
/* Bold is emphasis, not color — dark weight reads cleanly and lets the
terracotta accent stay meaningful (code, headings, links). */
.prose-chat :global(strong) {
color: var(--foreground);
font-weight: 600;
}
.prose-chat :global(a) {
color: var(--primary);
text-decoration: underline;
text-decoration-style: dotted;
text-underline-offset: 2px;
}
/* Input area ornament */
.input-ornament::before {
content: '';
position: absolute;
top: 0;
left: 2rem;
right: 2rem;
height: 1px;
background: linear-gradient(to right, transparent, var(--primary), transparent);
opacity: 0.3;
}
</style>

View File

@@ -2,11 +2,17 @@
// Global floating-window layer — mounted once in App.svelte, above every // Global floating-window layer — mounted once in App.svelte, above every
// page, so an entity opened from Knowledge Base, chat, or anywhere else // 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 // lands in the same window stack instead of each page owning its own
// single-entity sidebar/sheet. See $lib/stores/windows.ts. // single-entity sidebar/sheet. See $lib/stores/windows.ts. Minimized
// windows reopen from MinimizedWindowsBar, mounted separately inside the
// page layout (not here) so it takes real space instead of floating over
// content — see that component for why.
import { dk, wmState, openEntityWindow } from '$lib/stores/windows' import { dk, wmState, openEntityWindow } from '$lib/stores/windows'
import EntityDetailContent from './EntityDetailContent.svelte' import EntityDetailContent from './EntityDetailContent.svelte'
import SessionChatWindow from './SessionChatWindow.svelte'
import XIcon from '@lucide/svelte/icons/x' import XIcon from '@lucide/svelte/icons/x'
import MinusIcon from '@lucide/svelte/icons/minus' import MinusIcon from '@lucide/svelte/icons/minus'
const SESSION_PREFIX = 'session:'
</script> </script>
<div use:dk.desktop class="pointer-events-none fixed inset-0 z-40"> <div use:dk.desktop class="pointer-events-none fixed inset-0 z-40">
@@ -15,12 +21,12 @@
{#if win} {#if win}
<section use:dk.window={{ id }} class="min-w-0" aria-label={win.title}> <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"> <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> <span data-wm-title class="flex min-w-0 flex-1 items-center self-stretch truncate font-mono text-xs font-medium">{win.title}</span>
<div class="flex shrink-0 items-center gap-0.5"> <div class="flex shrink-0 items-center gap-0.5">
<button <button
type="button" type="button"
data-wm-minimize data-wm-minimize
class="rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground" class="flex items-center justify-center rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
aria-label="Minimize {win.title}" aria-label="Minimize {win.title}"
> >
<MinusIcon class="size-3.5" /> <MinusIcon class="size-3.5" />
@@ -28,7 +34,7 @@
<button <button
type="button" type="button"
data-wm-close data-wm-close
class="rounded p-1 text-muted-foreground hover:bg-destructive/10 hover:text-destructive" class="flex items-center justify-center rounded p-1 text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
aria-label="Close {win.title}" aria-label="Close {win.title}"
> >
<XIcon class="size-3.5" /> <XIcon class="size-3.5" />
@@ -37,7 +43,11 @@
</header> </header>
<div data-wm-content class="min-h-0 flex-1 overflow-hidden"> <div data-wm-content class="min-h-0 flex-1 overflow-hidden">
{#key id} {#key id}
{#if id.startsWith(SESSION_PREFIX)}
<SessionChatWindow sessionId={id.slice(SESSION_PREFIX.length)} />
{:else}
<EntityDetailContent slug={id} onSelectEntity={openEntityWindow} /> <EntityDetailContent slug={id} onSelectEntity={openEntityWindow} />
{/if}
{/key} {/key}
</div> </div>
</section> </section>

View File

@@ -1,13 +1,11 @@
<script lang="ts"> <script lang="ts">
import { onMount, onDestroy } from 'svelte' import { onMount, onDestroy } from 'svelte'
import { forceSimulation, forceLink, forceManyBody, forceCenter, forceCollide, forceX, forceY, type Simulation } from 'd3-force' import { forceSimulation, forceLink, forceManyBody, forceCenter, forceCollide, forceX, forceY, type Simulation } from 'd3-force'
import { fetchGraph, fetchEntityTypes, type GraphView, type Entity, type Health } from '$lib/api' import { fetchGraph, type GraphView, type Entity, type Health } from '$lib/api'
import { liveEvents, subscribeEvents } from '$lib/stores/events' import { liveEvents, subscribeEvents } from '$lib/stores/events'
import { typeToCategory, type Category } from '$lib/categories'
import { Skeleton } from '$lib/components/ui/skeleton' import { Skeleton } from '$lib/components/ui/skeleton'
export interface GraphInfo { export interface GraphInfo {
allNodeTypes: string[]
allRelTypes: string[] allRelTypes: string[]
relColors: Map<string, string> relColors: Map<string, string>
visibleCount: number visibleCount: number
@@ -16,7 +14,6 @@
} }
let { let {
category,
selectedSlug = null, selectedSlug = null,
onSelect, onSelect,
root = $bindable(''), root = $bindable(''),
@@ -24,11 +21,12 @@
search, search,
reloadToken, reloadToken,
resetToken, resetToken,
activeNodeTypes = $bindable(new Set<string>()), // Owned by the parent (shared with the entity table's type filter) —
// this graph only reads it to decide what's in focus, never writes it.
activeNodeTypes,
activeRelTypes = $bindable(new Set<string>()), activeRelTypes = $bindable(new Set<string>()),
info = $bindable<GraphInfo>({ allNodeTypes: [], allRelTypes: [], relColors: new Map(), visibleCount: 0, truncated: false, zoomPct: 100 }) info = $bindable<GraphInfo>({ allRelTypes: [], relColors: new Map(), visibleCount: 0, truncated: false, zoomPct: 100 })
}: { }: {
category: Category
selectedSlug?: string | null selectedSlug?: string | null
onSelect: (slug: string | null) => void onSelect: (slug: string | null) => void
root?: string root?: string
@@ -39,7 +37,7 @@
// this resizable pane), so they can't call load()/resetView() directly. // this resizable pane), so they can't call load()/resetView() directly.
reloadToken: number reloadToken: number
resetToken: number resetToken: number
activeNodeTypes?: Set<string> activeNodeTypes: Set<string>
activeRelTypes?: Set<string> activeRelTypes?: Set<string>
info?: GraphInfo info?: GraphInfo
} = $props() } = $props()
@@ -59,19 +57,20 @@
type: string type: string
} }
// SVG ids are document-global, not scoped to this <svg> — see
// SessionGraph.svelte's dotGridId for why this needs a per-instance suffix
// (also covers the per-relationship-type arrow markers below, which were
// keyed only by type name and would collide the same way across two
// mounted EntityGraph instances).
const uid = crypto.randomUUID().slice(0, 8)
const dotGridId = `dot-grid-${uid}`
let graph = $state<GraphView | null>(null) let graph = $state<GraphView | null>(null)
let loading = $state(true) let loading = $state(true)
let nodes = $state<Node[]>([]) let nodes = $state<Node[]>([])
let links = $state<Link[]>([]) let links = $state<Link[]>([])
let sim: Simulation<Node, Link> | null = null let sim: Simulation<Node, Link> | null = null
// type → browsing category, so the graph can be scoped client-side (the
// graph endpoint itself has no category/domain param). Value is undefined
// for types deliberately excluded from every category (e.g. execution/
// check/task — see categories.ts); the key is still present so inCategory
// can tell "excluded on purpose" apart from "not in the ontology at all."
let typeCategory = $state<Map<string, Category | undefined>>(new Map())
let hoveredId = $state<string | null>(null) let hoveredId = $state<string | null>(null)
// viewport transform: translate(x, y) scale(k) // viewport transform: translate(x, y) scale(k)
@@ -101,7 +100,7 @@
} }
function markerId(type: string): string { function markerId(type: string): string {
return 'arrow-' + type.replace(/[^a-z0-9]/gi, '_') return `arrow-${uid}-` + type.replace(/[^a-z0-9]/gi, '_')
} }
function endpoint(end: string | Node): Node | undefined { function endpoint(end: string | Node): Node | undefined {
@@ -111,46 +110,7 @@
return typeof end === 'object' ? end.id : end return typeof end === 'object' ? end.id : end
} }
// Node belongs to the active category? Types the ontology never returned async function load() {
// at all fall back to visible (so a missing entry never blanks the
// graph); types the ontology returned but categories.ts deliberately
// excludes (key present, value undefined) do not.
function inCategory(type: string): boolean {
if (!typeCategory.has(type)) return true
return typeCategory.get(type) === category
}
// Brand-new nodes (no `prev`) get x/y left undefined, and d3-force's
// default init spreads those via a spiral centered on the ORIGIN — not
// (width/2, height/2) — while the x/y centering forces below are
// deliberately weak (0.04, so they don't fight the link/collide layout).
// Together that meant the cluster could settle noticeably off-origin
// instead of centered. Fixed by explicitly fitting the viewport to the
// node bounding box once the simulation settles, rather than relying on
// the force balance to land on center by itself.
function fitToView() {
const placed = nodes.filter((n) => n.x != null && n.y != null)
if (!placed.length) return
const xs = placed.map((n) => n.x as number)
const ys = placed.map((n) => n.y as number)
const minX = Math.min(...xs)
const maxX = Math.max(...xs)
const minY = Math.min(...ys)
const maxY = Math.max(...ys)
const pad = 70
const bw = Math.max(maxX - minX, 1)
const bh = Math.max(maxY - minY, 1)
const k = Math.min((width - pad * 2) / bw, (height - pad * 2) / bh, 2.5)
const cx = (minX + maxX) / 2
const cy = (minY + maxY) / 2
view = { k, x: width / 2 - cx * k, y: height / 2 - cy * k }
}
// fit=false for passive background reloads (live entity/relationship
// events) — those shouldn't yank the view out from under someone
// actively panning/zooming. Fresh loads (mount, root/depth change,
// reset, re-root) default to fit=true.
async function load(fit = true) {
loading = true loading = true
graph = await fetchGraph({ root: root || undefined, depth, includeStatus: true }) graph = await fetchGraph({ root: root || undefined, depth, includeStatus: true })
loading = false loading = false
@@ -176,8 +136,8 @@
type: e.type type: e.type
})) }))
// Default the node/edge-type toggles to the types present in the active category. // Edge-type toggles default to everything present — node-type toggles
activeNodeTypes = new Set(nodes.filter((n) => inCategory(n.type)).map((n) => n.type)) // are owned by the parent (activeNodeTypes) and persist across reloads.
activeRelTypes = new Set(links.map((l) => l.type)) activeRelTypes = new Set(links.map((l) => l.type))
sim?.stop() sim?.stop()
@@ -193,17 +153,9 @@
.on('tick', () => { .on('tick', () => {
nodes = [...nodes] nodes = [...nodes]
}) })
.on('end', () => {
if (fit) fitToView()
})
} }
onMount(() => { onMount(() => {
fetchEntityTypes().then((types) => {
typeCategory = new Map(types.map((t) => [t.name, typeToCategory(t.name, t.domain)]))
// Re-derive the active node types now that category membership is known.
activeNodeTypes = new Set(nodes.filter((n) => inCategory(n.type)).map((n) => n.type))
})
load() load()
const unsubscribe = subscribeEvents() const unsubscribe = subscribeEvents()
return () => { return () => {
@@ -214,17 +166,11 @@
onDestroy(() => sim?.stop()) onDestroy(() => sim?.stop())
// When the category perspective changes, reset the node-type toggles to it.
$effect(() => {
category
activeNodeTypes = new Set(nodes.filter((n) => inCategory(n.type)).map((n) => n.type))
})
$effect(() => { $effect(() => {
const ev = $liveEvents[0] const ev = $liveEvents[0]
if (!ev) return if (!ev) return
if (ev.type.startsWith('entity.') || ev.type.startsWith('relationship.') || ev.type === 'health.changed') { if (ev.type.startsWith('entity.') || ev.type.startsWith('relationship.') || ev.type === 'health.changed') {
load(false) load()
} }
}) })
@@ -266,14 +212,11 @@
return 5 + Math.min(Math.sqrt(node.degree) * 1.6, 7) return 5 + Math.min(Math.sqrt(node.degree) * 1.6, 7)
} }
// Only offer node-type toggles that live in the active category.
const allNodeTypes = $derived(Array.from(new Set(nodes.filter((n) => inCategory(n.type)).map((n) => n.type))).sort())
const allRelTypes = $derived(Array.from(new Set(links.map((l) => l.type))).sort()) const allRelTypes = $derived(Array.from(new Set(links.map((l) => l.type))).sort())
// Publish status/legend info up to the parent toolbar. // Publish status/legend info up to the parent toolbar.
$effect(() => { $effect(() => {
info = { info = {
allNodeTypes,
allRelTypes, allRelTypes,
relColors: relColorByType, relColors: relColorByType,
visibleCount: visibleNodeIds.size, visibleCount: visibleNodeIds.size,
@@ -288,21 +231,21 @@
return new Set(nodes.filter((n) => n.slug.toLowerCase().includes(q) || n.name.toLowerCase().includes(q)).map((n) => n.id)) return new Set(nodes.filter((n) => n.slug.toLowerCase().includes(q) || n.name.toLowerCase().includes(q)).map((n) => n.id))
}) })
// Focus = in the active category AND its node-type toggle is on — these // Focus = the shared type multiselect (activeNodeTypes) says this type is
// are what the category tab is "about." // visible — same control the entity table filters its rows by.
const focusNodeIds = $derived(new Set(nodes.filter((n) => inCategory(n.type) && activeNodeTypes.has(n.type)).map((n) => n.id))) const focusNodeIds = $derived(new Set(nodes.filter((n) => activeNodeTypes.has(n.type)).map((n) => n.id)))
// Real infra relationships mostly cross category lines (a service sits on // Real infra relationships mostly cross type lines (a service sits on a
// a network, uses storage, runs on an lxc — different categories under // network, uses storage, runs on an lxc). Hard-hiding any edge whose
// this taxonomy). Hard-hiding any edge whose other end isn't in-category // other end isn't in the active type set left focus nodes looking like
// left focus nodes looking like disconnected dots. Rooted views (the user // disconnected dots. Rooted views (the user is exploring out from one
// is exploring out from one entity) pull in 1-hop neighbors of any // entity) pull in 1-hop neighbors of any type, dimmed, so the edges — and
// category, dimmed, so the edges — and what they connect to — stay // what they connect to — stay visible. Unscoped "browse everything" views
// visible. Unscoped "browse the whole category" views (no root) skip // (no root) skip this: with dozens of focus nodes that touch nearly
// this: with ~50 focus nodes that touch nearly everything, 1-hop // everything, 1-hop expansion floods in most of the graph (measured: 417
// expansion floods in most of the graph (measured: 417 of 479 total // of 479 total entities for an unrooted Fleet-typed view) — worse than
// entities for an unrooted Fleet view) — worse than the isolated-dot // the isolated-dot problem it was meant to fix. There, same-type-only
// problem it was meant to fix. There, same-category-only edges stay. // edges stay.
const neighborNodeIds = $derived.by(() => { const neighborNodeIds = $derived.by(() => {
const neighbors = new Set<string>() const neighbors = new Set<string>()
if (!root.trim()) return neighbors if (!root.trim()) return neighbors
@@ -452,7 +395,7 @@
onpointercancel={onPointerUp} onpointercancel={onPointerUp}
> >
<defs> <defs>
<pattern id="dot-grid" width="12" height="12" patternUnits="userSpaceOnUse"> <pattern id={dotGridId} width="12" height="12" patternUnits="userSpaceOnUse">
<circle cx="2" cy="2" r="0.8" fill="var(--border)" opacity="0.75" /> <circle cx="2" cy="2" r="0.8" fill="var(--border)" opacity="0.75" />
</pattern> </pattern>
{#each allRelTypes as type} {#each allRelTypes as type}
@@ -461,7 +404,7 @@
</marker> </marker>
{/each} {/each}
</defs> </defs>
<rect x="0" y="0" width={width} height={height} fill="url(#dot-grid)" /> <rect x="0" y="0" width={width} height={height} fill="url(#{dotGridId})" />
<g transform="translate({view.x},{view.y}) scale({view.k})"> <g transform="translate({view.x},{view.y}) scale({view.k})">
<g> <g>
{#each links as link} {#each links as link}
@@ -472,25 +415,31 @@
{@const dx = t.x - s.x} {@const dx = t.x - s.x}
{@const dy = t.y - s.y} {@const dy = t.y - s.y}
{@const len = Math.max(Math.hypot(dx, dy), 1)} {@const len = Math.max(Math.hypot(dx, dy), 1)}
{@const curve = Math.min(len * 0.15, 40)}
{@const cx = (s.x + t.x) / 2 - (dy / len) * curve}
{@const cy = (s.y + t.y) / 2 + (dx / len) * curve}
{@const cdx = t.x - cx}
{@const cdy = t.y - cy}
{@const clen = Math.max(Math.hypot(cdx, cdy), 1)}
{@const tr = nodeRadius(t) + 3} {@const tr = nodeRadius(t) + 3}
{@const ex = t.x - (dx / len) * tr} {@const ex = t.x - (cdx / clen) * tr}
{@const ey = t.y - (dy / len) * tr} {@const ey = t.y - (cdy / clen) * tr}
<line {@const mx = 0.25 * s.x + 0.5 * cx + 0.25 * ex}
x1={s.x} {@const my = 0.25 * s.y + 0.5 * cy + 0.25 * ey}
y1={s.y} <path
x2={ex} d="M {s.x},{s.y} Q {cx},{cy} {ex},{ey}"
y2={ey} fill="none"
stroke={relColor(link.type)} stroke={relColor(link.type)}
stroke-width={vs.emphasized ? 2 : 1.2} stroke-width={vs.emphasized ? 2 : 1.2}
opacity={vs.opacity} opacity={vs.opacity}
marker-end="url(#{markerId(link.type)})" marker-end="url(#{markerId(link.type)})"
> >
<title>{link.type}</title> <title>{link.type}</title>
</line> </path>
{#if vs.emphasized && view.k >= 0.7} {#if vs.emphasized && view.k >= 0.7}
<text <text
x={(s.x + ex) / 2} x={mx}
y={(s.y + ey) / 2 - 4} y={my - 4}
text-anchor="middle" text-anchor="middle"
font-size={10 / view.k} font-size={10 / view.k}
fill={relColor(link.type)} fill={relColor(link.type)}

View File

@@ -198,8 +198,14 @@
const z = (s.z + tg.z) / 2 const z = (s.z + tg.z) / 2
const a = project(s.x, s.y!, z) const a = project(s.x, s.y!, z)
const b = project(tg.x, tg.y!, z) const b = project(tg.x, tg.y!, z)
const dx = b.x - a.x
const dy = b.y - a.y
const len = Math.max(Math.hypot(dx, dy), 1)
const curve = Math.min(len * 0.15, 40)
const mx = (a.x + b.x) / 2 - (dy / len) * curve
const my = (a.y + b.y) / 2 + (dx / len) * curve
ctx.moveTo(a.x, a.y) ctx.moveTo(a.x, a.y)
ctx.lineTo(b.x, b.y) ctx.quadraticCurveTo(mx, my, b.x, b.y)
} }
ctx.stroke() ctx.stroke()

View File

@@ -0,0 +1,36 @@
<script lang="ts">
// Deliberately mounted inside the page layout (App.svelte's Sidebar.Inset
// column, not alongside EntityDesktop's full-viewport overlay) — as a real
// flex child it takes its own height and the page content above it
// (min-h-0 flex-1) shrinks to make room, instead of floating over
// whatever's scrolled to the bottom.
import { wm, wmState } from '$lib/stores/windows'
import { truncateMiddle } from '$lib/utils'
import Maximize2Icon from '@lucide/svelte/icons/maximize-2'
const minimizedIds = $derived($wmState.order.filter((id) => $wmState.windows[id]?.stage === 'minimized'))
function restoreWindow(id: string) {
wm.restore(id)
wm.focus(id)
}
</script>
{#if minimizedIds.length}
<div class="flex shrink-0 items-center gap-1.5 overflow-x-auto border-t bg-muted/30 px-2 py-1.5">
{#each minimizedIds as id (id)}
{@const win = $wmState.windows[id]}
{#if win}
<button
type="button"
class="group flex max-w-56 shrink-0 items-center gap-2 rounded-md border bg-card py-1 pr-1.5 pl-2.5 font-mono text-xs hover:bg-muted"
onclick={() => restoreWindow(id)}
title={win.title}
>
<span class="min-w-0 truncate">{truncateMiddle(win.title, 32)}</span>
<Maximize2Icon class="size-3 shrink-0 text-muted-foreground group-hover:text-foreground" aria-hidden="true" />
</button>
{/if}
{/each}
</div>
{/if}

View File

@@ -1,17 +1,18 @@
<script lang="ts"> <script lang="ts">
import { openQuestion } from '$lib/stores/workspace' import { answerQuestion as postAnswer, type SessionQuestion } from '$lib/api'
import { currentSession } from '$lib/stores/chat'
import { answerQuestion as postAnswer } from '$lib/api'
import { Button } from '$lib/components/ui/button' import { Button } from '$lib/components/ui/button'
import { Textarea } from '$lib/components/ui/textarea' import { Textarea } from '$lib/components/ui/textarea'
import CircleHelpIcon from '@lucide/svelte/icons/circle-help' import CircleHelpIcon from '@lucide/svelte/icons/circle-help'
// Prop-driven (not store-imported) — see SessionGraph.svelte for why.
let { sessionId, question }: { sessionId: string | null; question: SessionQuestion | null } = $props()
let freeText = $state('') let freeText = $state('')
let submitting = $state(false) let submitting = $state(false)
async function submit(answer: string) { async function submit(answer: string) {
const sid = $currentSession const sid = sessionId
const q = $openQuestion const q = question
if (!sid || !q || !answer.trim() || submitting) return if (!sid || !q || !answer.trim() || submitting) return
submitting = true submitting = true
const ok = await postAnswer(sid, q.id, answer.trim()) const ok = await postAnswer(sid, q.id, answer.trim())
@@ -23,8 +24,8 @@
} }
</script> </script>
{#if $openQuestion} {#if question}
{@const q = $openQuestion} {@const q = question}
<div class="flex shrink-0 flex-col gap-2 border-b bg-warning/5 px-3 py-2.5"> <div class="flex shrink-0 flex-col gap-2 border-b bg-warning/5 px-3 py-2.5">
<div class="flex items-start gap-2"> <div class="flex items-start gap-2">
<CircleHelpIcon class="mt-0.5 size-4 shrink-0 text-warning" /> <CircleHelpIcon class="mt-0.5 size-4 shrink-0 text-warning" />

View File

@@ -0,0 +1,91 @@
<script lang="ts">
// Floating-window content for a task/session — the per-window counterpart
// to the main Chat page (thread + rail), fully self-contained per
// sessionId via chat.ts's chatFor()/loadSessionChat()/sendSessionMessage()
// and workspace.ts's workspaceFor()/startSessionWorkspace(), so several of
// these can be open (and independently live) at once without the "which
// one's on screen" guarding the main page's singleton stores need.
import { onDestroy, onMount } from 'svelte'
import { chatFor, loadSessionChat, sendSessionMessage, cancelSessionStream, stopSessionPolling, dismissError, chatErrors } from '$lib/stores/chat'
import ChatThread from '$lib/components/ChatThread.svelte'
import TaskContextPanel from '$lib/components/TaskContextPanel.svelte'
let { sessionId }: { sessionId: string } = $props()
// Svelte's `$store` auto-subscription only works on a plain identifier
// bound directly to a store, not a member expression — chatFor() returns
// an object of stores, so pull each one out into its own identifier here.
const chat = chatFor(sessionId)
const chatMessages = chat.messages
const chatStreaming = chat.streaming
const chatConnectionState = chat.connectionState
const chatError = chat.error
let loading = $state(true)
onMount(async () => {
await loadSessionChat(sessionId)
loading = false
})
onDestroy(() => stopSessionPolling(sessionId))
// Resizable right rail — same behavior as Chat.svelte's, sized smaller by
// default since task windows open narrower than the full page.
const RAIL_MIN = 220
const RAIL_MAX = 480
let railWidth = $state(260)
let resizing = $state(false)
function startResize(e: PointerEvent) {
e.preventDefault()
resizing = true
const startX = e.clientX
const startW = railWidth
function move(ev: PointerEvent) {
railWidth = Math.min(RAIL_MAX, Math.max(RAIL_MIN, startW + (startX - ev.clientX)))
}
function up() {
resizing = false
window.removeEventListener('pointermove', move)
window.removeEventListener('pointerup', up)
}
window.addEventListener('pointermove', move)
window.addEventListener('pointerup', up)
}
</script>
<div class="flex h-full min-h-0">
{#if loading}
<div class="flex flex-1 items-center justify-center text-xs text-muted-foreground">Loading…</div>
{:else}
<ChatThread
messages={$chatMessages}
streaming={$chatStreaming}
connectionState={$chatConnectionState}
error={$chatError}
chatErrors={$chatErrors}
onSend={(text) => sendSessionMessage(sessionId, text)}
onCancel={() => cancelSessionStream(sessionId)}
onReconnect={() => loadSessionChat(sessionId)}
onDismissError={dismissError}
/>
<div class="flex shrink-0" style="width: {railWidth}px">
<button
type="button"
class="group/rz relative w-1.5 shrink-0 cursor-col-resize touch-none"
onpointerdown={startResize}
aria-label="Resize task 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 min-w-0 flex-1 flex-col">
<TaskContextPanel {sessionId} />
</div>
</div>
{/if}
</div>

View File

@@ -11,10 +11,22 @@
type Simulation type Simulation
} from 'd3-force' } from 'd3-force'
import { fetchGraph, type Entity } from '$lib/api' import { fetchGraph, type Entity } from '$lib/api'
import { messages } from '$lib/stores/chat' import type { ChatMessage } from '$lib/stores/chat'
import { touched, healthDiffs } from '$lib/stores/workspace' import type { TouchedEntity, HealthDiff } from '$lib/stores/workspace'
import { openEntityWindow, wmState } from '$lib/stores/windows' import { openEntityWindow, wmState } from '$lib/stores/windows'
// Prop-driven (not store-imported) so this can render either the main
// page's global "current session" data or a floating task window's own
// per-session data — see TaskContextPanel.svelte, which supplies both.
let { messages, touched, healthDiffs }: { messages: ChatMessage[]; touched: TouchedEntity[]; healthDiffs: HealthDiff[] } = $props()
// SVG ids are document-global, not scoped to this <svg> — several task
// windows can each have their own Scope graph open at once, and without a
// per-instance suffix every one of them would define (and reference)
// <pattern id="dot-grid">, so only the first in the document would ever
// actually paint (the rest resolve to nothing, background reads blank).
const dotGridId = `dot-grid-${crypto.randomUUID().slice(0, 8)}`
interface Node extends Entity { interface Node extends Entity {
x?: number x?: number
y?: number y?: number
@@ -75,7 +87,7 @@
// get_health_summary would otherwise dump all 168 entities into the graph). // get_health_summary would otherwise dump all 168 entities into the graph).
const candidateSlugs = $derived.by(() => { const candidateSlugs = $derived.by(() => {
const out = new Set<string>() const out = new Set<string>()
for (const m of $messages) { for (const m of messages) {
collectSlugs(m.text, out) collectSlugs(m.text, out)
for (const t of m.tools) collectSlugs(t.args, out) for (const t of m.tools) collectSlugs(t.args, out)
} }
@@ -234,15 +246,15 @@
// object identity fine and this is small (≤12 touched, ≤8 diffs). // object identity fine and this is small (≤12 touched, ≤8 diffs).
const touchedBySlug = $derived.by(() => { const touchedBySlug = $derived.by(() => {
const m: Record<string, true> = {} const m: Record<string, true> = {}
for (const t of $touched) m[t.slug] = true for (const t of touched) m[t.slug] = true
return m return m
}) })
const diffBySlug = $derived.by(() => { const diffBySlug = $derived.by(() => {
const m: Record<string, { from: string; to: string }> = {} const m: Record<string, { from: string; to: string }> = {}
for (const d of $healthDiffs) if (!(d.slug in m)) m[d.slug] = d for (const d of healthDiffs) if (!(d.slug in m)) m[d.slug] = d
return m return m
}) })
const nowTouching = $derived($touched[0] ?? null) const nowTouching = $derived(touched[0] ?? null)
function endpoint(end: string | Node): Node | undefined { function endpoint(end: string | Node): Node | undefined {
return typeof end === 'object' ? end : nodes.find((n) => n.slug === end) return typeof end === 'object' ? end : nodes.find((n) => n.slug === end)
@@ -348,28 +360,32 @@
onpointercancel={onUp} onpointercancel={onUp}
> >
<defs> <defs>
<pattern id="dot-grid" width="12" height="12" patternUnits="userSpaceOnUse"> <pattern id={dotGridId} width="12" height="12" patternUnits="userSpaceOnUse">
<circle cx="2" cy="2" r="0.8" fill="var(--border)" opacity="0.75" /> <circle cx="2" cy="2" r="0.8" fill="var(--border)" opacity="0.75" />
</pattern> </pattern>
</defs> </defs>
<rect width={cw} height={ch} fill="url(#dot-grid)" /> <rect width={cw} height={ch} fill="url(#{dotGridId})" />
<g> <g>
{#each links as link} {#each links as link}
{@const s = endpoint(link.source)} {@const s = endpoint(link.source)}
{@const t = endpoint(link.target)} {@const t = endpoint(link.target)}
{#if s?.x != null && t?.x != null && s?.y != null && t?.y != null} {#if s?.x != null && t?.x != null && s?.y != null && t?.y != null}
{@const focus = selected && (s.slug === selected.slug || t.slug === selected.slug)} {@const focus = selected && (s.slug === selected.slug || t.slug === selected.slug)}
<line {@const dx = t.x - s.x}
x1={s.x} {@const dy = t.y - s.y}
y1={s.y} {@const len = Math.max(Math.hypot(dx, dy), 1)}
x2={t.x} {@const curve = Math.min(len * 0.15, 40)}
y2={t.y} {@const cx = (s.x + t.x) / 2 - (dy / len) * curve}
{@const cy = (s.y + t.y) / 2 + (dx / len) * curve}
<path
d="M {s.x},{s.y} Q {cx},{cy} {t.x},{t.y}"
fill="none"
stroke="var(--muted-foreground)" stroke="var(--muted-foreground)"
stroke-width={focus ? 1.6 : 1} stroke-width={focus ? 1.6 : 1}
opacity={selected ? (focus ? 0.7 : 0.12) : 0.35} opacity={selected ? (focus ? 0.7 : 0.12) : 0.35}
> >
<title>{link.type}</title> <title>{link.type}</title>
</line> </path>
{/if} {/if}
{/each} {/each}
</g> </g>

View File

@@ -1,8 +1,8 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte' import { onMount } from 'svelte'
import { startWorkspace, planSteps, currentTask, touched } from '$lib/stores/workspace' import { startWorkspace, startSessionWorkspace, planSteps, currentTask, openQuestion, touched, healthDiffs, workspaceFor, taskFor } from '$lib/stores/workspace'
import { activityLog } from '$lib/stores/activity' import { streaming, messages, currentSession, chatFor } from '$lib/stores/chat'
import { streaming } from '$lib/stores/chat' import { activityLog, activityLogFor } from '$lib/stores/activity'
import OperatorQuestion from './OperatorQuestion.svelte' import OperatorQuestion from './OperatorQuestion.svelte'
import SessionGraph from './SessionGraph.svelte' import SessionGraph from './SessionGraph.svelte'
import ActivityTimeline from './ActivityTimeline.svelte' import ActivityTimeline from './ActivityTimeline.svelte'
@@ -16,7 +16,29 @@
import CircleSlashIcon from '@lucide/svelte/icons/circle-slash' import CircleSlashIcon from '@lucide/svelte/icons/circle-slash'
import CirclePauseIcon from '@lucide/svelte/icons/circle-pause' import CirclePauseIcon from '@lucide/svelte/icons/circle-pause'
onMount(() => startWorkspace()) // Omitted (main Chat page): tracks the global "current session" — one
// shared view, same as always. Passed (a floating task window's
// SessionChatWindow): this panel switches entirely to that session's own
// store bundle (workspaceFor/chatFor/activityLogFor), so several windows'
// panels can be open and live at once instead of all showing whatever
// happens to be the single global "current session".
let { sessionId = null }: { sessionId?: string | null } = $props()
onMount(() => (sessionId ? startSessionWorkspace(sessionId) : startWorkspace()))
const ws = $derived(sessionId ? workspaceFor(sessionId) : null)
const planStepsStore = $derived(ws ? ws.planSteps : planSteps)
const openQuestionStore = $derived(ws ? ws.openQuestion : openQuestion)
const touchedStore = $derived(ws ? ws.touched : touched)
const healthDiffsStore = $derived(ws ? ws.healthDiffs : healthDiffs)
const taskStore = $derived(sessionId ? taskFor(sessionId) : currentTask)
const chat = $derived(sessionId ? chatFor(sessionId) : null)
const streamingStore = $derived(chat ? chat.streaming : streaming)
const messagesStore = $derived(chat ? chat.messages : messages)
const activityLogStore = $derived(sessionId ? activityLogFor(sessionId) : activityLog)
// OperatorQuestion posts its answer against this id — the window's own
// session when set, otherwise whatever the main page currently has open.
const effectiveSessionId = $derived(sessionId ?? $currentSession)
let scopeOpen = $state(true) let scopeOpen = $state(true)
let planOpen = $state(true) let planOpen = $state(true)
@@ -58,8 +80,8 @@
} }
// Plan collapsed status // Plan collapsed status
const planDone = $derived($planSteps.filter((s) => s.status === 'done').length) const planDone = $derived($planStepsStore.filter((s) => s.status === 'done').length)
const planTotal = $derived($planSteps.length) const planTotal = $derived($planStepsStore.length)
const planPct = $derived(planTotal > 0 ? Math.round((planDone / planTotal) * 100) : 0) const planPct = $derived(planTotal > 0 ? Math.round((planDone / planTotal) * 100) : 0)
// When there are no plan steps, the empty state depends on WHY: a task that's // When there are no plan steps, the empty state depends on WHY: a task that's
@@ -67,19 +89,19 @@
// but a finished task that never planned (a read-only lookup, a direct answer) // but a finished task that never planned (a read-only lookup, a direct answer)
// will never get one — a perpetual "Awaiting plan…" there is misleading. // will never get one — a perpetual "Awaiting plan…" there is misleading.
const planPhase = $derived.by<'drafting' | 'none' | 'idle'>(() => { const planPhase = $derived.by<'drafting' | 'none' | 'idle'>(() => {
const st = $currentTask?.status const st = $taskStore?.status
if (st === 'done' || st === 'failed' || st === 'abandoned') return 'none' if (st === 'done' || st === 'failed' || st === 'abandoned') return 'none'
if (st === 'planning' || $streaming) return 'drafting' if (st === 'planning' || $streamingStore) return 'drafting'
return 'idle' return 'idle'
}) })
// Activity collapsed status // Activity collapsed status
const activityRunning = $derived($activityLog.filter((e) => e.status === 'running').length) const activityRunning = $derived($activityLogStore.filter((e) => e.status === 'running').length)
const activityCount = $derived($activityLog.length) const activityCount = $derived($activityLogStore.length)
</script> </script>
<div class="flex h-full min-h-0 flex-col"> <div class="flex h-full min-h-0 flex-col">
<OperatorQuestion /> <OperatorQuestion sessionId={effectiveSessionId} question={$openQuestionStore} />
<!-- Scope --> <!-- Scope -->
<div class="flex shrink-0 flex-col border-b"> <div class="flex shrink-0 flex-col border-b">
@@ -91,12 +113,12 @@
{#if scopeOpen}<ChevronDownIcon class="size-3" />{:else}<ChevronRightIcon class="size-3" />{/if} {#if scopeOpen}<ChevronDownIcon class="size-3" />{:else}<ChevronRightIcon class="size-3" />{/if}
<span>Scope</span> <span>Scope</span>
{#if !scopeOpen} {#if !scopeOpen}
<span class="ml-auto font-normal normal-case">{$touched.length ? `${$touched.length} entit${$touched.length === 1 ? 'y' : 'ies'}` : 'Graph'}</span> <span class="ml-auto font-normal normal-case">{$touchedStore.length ? `${$touchedStore.length} entit${$touchedStore.length === 1 ? 'y' : 'ies'}` : 'Graph'}</span>
{/if} {/if}
</button> </button>
{#if scopeOpen} {#if scopeOpen}
<div style="height: {heights[0]}px"> <div style="height: {heights[0]}px">
<SessionGraph /> <SessionGraph messages={$messagesStore} touched={$touchedStore} healthDiffs={$healthDiffsStore} />
</div> </div>
<!-- resize handle --> <!-- resize handle -->
<div <div
@@ -120,8 +142,8 @@
{#if !planOpen} {#if !planOpen}
{#if planTotal > 0} {#if planTotal > 0}
<span class="ml-auto font-normal normal-case">Step {planDone}/{planTotal}</span> <span class="ml-auto font-normal normal-case">Step {planDone}/{planTotal}</span>
{:else if $currentTask?.goal} {:else if $taskStore?.goal}
<span class="ml-auto max-w-[120px] truncate font-normal normal-case">{$currentTask.goal}</span> <span class="ml-auto max-w-[120px] truncate font-normal normal-case">{$taskStore.goal}</span>
{:else} {:else}
<span class="ml-auto font-normal normal-case text-muted-foreground">No plan yet</span> <span class="ml-auto font-normal normal-case text-muted-foreground">No plan yet</span>
{/if} {/if}
@@ -129,10 +151,10 @@
</button> </button>
{#if planOpen} {#if planOpen}
<div style="height: {heights[1]}px" class="flex flex-col overflow-y-auto"> <div style="height: {heights[1]}px" class="flex flex-col overflow-y-auto">
{#if $currentTask?.goal} {#if $taskStore?.goal}
<div class="flex items-start gap-2 px-3 py-2"> <div class="flex items-start gap-2 px-3 py-2">
<MilestoneIcon class="mt-0.5 size-3 shrink-0 text-primary" /> <MilestoneIcon class="mt-0.5 size-3 shrink-0 text-primary" />
<span class="text-xs leading-snug text-foreground/90">{$currentTask.goal}</span> <span class="text-xs leading-snug text-foreground/90">{$taskStore.goal}</span>
</div> </div>
{/if} {/if}
{#if planTotal > 0} {#if planTotal > 0}
@@ -146,11 +168,11 @@
</div> </div>
</div> </div>
<ol class="flex flex-col overflow-y-auto px-2 pb-2 text-[11px]"> <ol class="flex flex-col overflow-y-auto px-2 pb-2 text-[11px]">
{#each $planSteps as step, i (step.id)} {#each $planStepsStore as step, i (step.id)}
{@const isDone = step.status === 'done'} {@const isDone = step.status === 'done'}
{@const isRunning = step.status === 'running'} {@const isRunning = step.status === 'running'}
<li class="relative flex items-start gap-2.5 rounded-md px-2 py-1.5 transition-colors {isRunning ? 'bg-primary/5' : ''}"> <li class="relative flex items-start gap-2.5 rounded-md px-2 py-1.5 transition-colors {isRunning ? 'bg-primary/5' : ''}">
{#if i < $planSteps.length - 1} {#if i < $planStepsStore.length - 1}
<span class="pointer-events-none absolute bottom-[-2px] left-[13.5px] top-[22px] w-px bg-border" aria-hidden="true"></span> <span class="pointer-events-none absolute bottom-[-2px] left-[13.5px] top-[22px] w-px bg-border" aria-hidden="true"></span>
{/if} {/if}
<span class="relative z-10 mt-px flex size-3.5 shrink-0 items-center justify-center rounded-full bg-background"> <span class="relative z-10 mt-px flex size-3.5 shrink-0 items-center justify-center rounded-full bg-background">
@@ -242,7 +264,7 @@
{#if activityOpen}<ChevronDownIcon class="size-3" />{:else}<ChevronRightIcon class="size-3" />{/if} {#if activityOpen}<ChevronDownIcon class="size-3" />{:else}<ChevronRightIcon class="size-3" />{/if}
<span>Event log</span> <span>Event log</span>
{#if !activityOpen} {#if !activityOpen}
{#if $streaming && activityRunning > 0} {#if $streamingStore && activityRunning > 0}
<Spinner class="size-3 text-primary" /> <Spinner class="size-3 text-primary" />
<span class="font-normal normal-case text-primary">{activityRunning} running</span> <span class="font-normal normal-case text-primary">{activityRunning} running</span>
{:else} {:else}
@@ -252,7 +274,7 @@
</button> </button>
{#if activityOpen} {#if activityOpen}
<div class="min-h-0 flex-1 overflow-hidden"> <div class="min-h-0 flex-1 overflow-hidden">
<ActivityTimeline /> <ActivityTimeline entries={$activityLogStore} />
</div> </div>
{/if} {/if}
</div> </div>

View File

@@ -1,6 +1,7 @@
import { derived } from 'svelte/store' import { derived, type Readable } from 'svelte/store'
import { messages, type ToolCallResult } from './chat' import { messages, chatFor, type ChatMessage, type ToolCallResult } from './chat'
import { planSteps, currentTask } from './workspace' import { planSteps, currentTask, workspaceFor, taskFor } from './workspace'
import type { PlanStep, Session } from '$lib/api'
export { type ToolCallResult } export { type ToolCallResult }
@@ -39,7 +40,10 @@ function stringifyResult(result: unknown): string {
return s.length > DETAIL_MAX ? `${s.slice(0, DETAIL_MAX)}\n… truncated` : s return s.length > DETAIL_MAX ? `${s.slice(0, DETAIL_MAX)}\n… truncated` : s
} }
export const activityLog = derived([messages, planSteps, currentTask], ([$msgs, $steps, $task]) => { // Pure derivation, parameterized so it can back both the global "current
// session" activityLog below and a per-session activityLogFor(sessionId) for
// a floating task window.
function computeActivityLog($msgs: ChatMessage[], $steps: PlanStep[], $task: Session | null): ActivityEntry[] {
const entries: ActivityEntry[] = [] const entries: ActivityEntry[] = []
const now = Date.now() const now = Date.now()
@@ -168,7 +172,18 @@ export const activityLog = derived([messages, planSteps, currentTask], ([$msgs,
entries.sort((a, b) => a.timestamp - b.timestamp) entries.sort((a, b) => a.timestamp - b.timestamp)
return entries return entries
}) }
export const activityLog = derived([messages, planSteps, currentTask], ([$msgs, $steps, $task]) =>
computeActivityLog($msgs, $steps, $task)
)
export function activityLogFor(sessionId: string): Readable<ActivityEntry[]> {
const chat = chatFor(sessionId)
const ws = workspaceFor(sessionId)
const task = taskFor(sessionId)
return derived([chat.messages, ws.planSteps, task], ([$msgs, $steps, $task]) => computeActivityLog($msgs, $steps, $task))
}
function toolActivityLabel(t: ToolCallResult): string { function toolActivityLabel(t: ToolCallResult): string {
const args = t.args ?? {} const args = t.args ?? {}

View File

@@ -1,4 +1,4 @@
import { writable, get } from 'svelte/store' import { writable, get, type Writable } from 'svelte/store'
import { streamChat, fetchSessions, fetchMessages, deleteSession as apiDeleteSession } from '$lib/api' import { streamChat, fetchSessions, fetchMessages, deleteSession as apiDeleteSession } from '$lib/api'
import type { ChatEvent, Session, Message } from '$lib/api' import type { ChatEvent, Session, Message } from '$lib/api'
import type { ToolCallResult } from '$lib/types' import type { ToolCallResult } from '$lib/types'
@@ -481,3 +481,172 @@ export async function deleteSession(sessionId: string) {
} }
loadSessions() loadSessions()
} }
// ─── per-session chat state, for floating task windows ─────────────────────
//
// Everything above this point is the single "whatever's on screen" view used
// by the main Chat page and the chat drawer — one global `currentSession`,
// one `messages` array, guarded so a background stream never clobbers the
// view. Floating task windows break that assumption: several sessions can be
// open and legitimately streaming at once, each wanting its own live
// transcript. Rather than retrofit the guard-heavy logic above (streamed
// events checking `get(currentSession) === streamSessionID` before applying),
// each window gets its own isolated store bundle keyed by session id, so
// there's nothing to guard — events for session X always land in X's own
// bundle regardless of what else is open or on screen.
export interface SessionChatState {
messages: Writable<ChatMessage[]>
streaming: Writable<boolean>
connectionState: Writable<'connected' | 'disconnected' | 'reconnecting'>
error: Writable<string | null>
}
const sessionChats = new Map<string, SessionChatState>()
const sessionPollers = new Map<string, ReturnType<typeof setInterval>>()
// Lazily creates (and memoizes) the store bundle for a session — call this to
// get the stores to subscribe to; it does not fetch anything.
export function chatFor(sessionId: string): SessionChatState {
let c = sessionChats.get(sessionId)
if (!c) {
c = { messages: writable([]), streaming: writable(false), connectionState: writable('connected'), error: writable(null) }
sessionChats.set(sessionId, c)
}
return c
}
function startSessionPolling(sessionId: string) {
const existing = sessionPollers.get(sessionId)
if (existing) clearInterval(existing)
const chat = chatFor(sessionId)
sessionPollers.set(
sessionId,
setInterval(async () => {
if (get(chat.streaming) && get(chat.connectionState) === 'connected') return
const msgs = await fetchMessages(sessionId)
if (get(chat.streaming)) return // re-check: the fetch itself takes time
chat.messages.set(toChatMessages(msgs))
}, 3000)
)
}
export function stopSessionPolling(sessionId: string) {
const t = sessionPollers.get(sessionId)
if (t) {
clearInterval(t)
sessionPollers.delete(sessionId)
}
}
// Fetches sessionId's current transcript into its own store bundle and
// starts polling it for auto-continuation updates — the per-session
// equivalent of loadSessionMessages, for a window rather than the main view.
export async function loadSessionChat(sessionId: string): Promise<void> {
const chat = chatFor(sessionId)
chat.streaming.set(false)
const msgs = await fetchMessages(sessionId)
chat.messages.set(toChatMessages(msgs))
startSessionPolling(sessionId)
}
// Per-session equivalent of sendMessage — writes into sessionId's own store
// bundle unconditionally (no "is this still on screen" guard needed, since
// the bundle IS the screen for this session's window) and shares
// `activeControllers` with the singleton path above so cancelStream() from
// either a window or the main view (if the same session happens to be open
// in both) finds the same in-flight call.
export function sendSessionMessage(sessionId: string, text: string) {
const chat = chatFor(sessionId)
chat.error.set(null)
chat.streaming.set(true)
const userMsg: ChatMessage = { id: mid(), role: 'user', text, tools: [], pendingApprovals: [] }
chat.messages.update((ms) => [...ms, userMsg])
const assistantMsg: ChatMessage = { id: mid(), role: 'assistant', text: '', tools: [], pendingApprovals: [] }
chat.messages.update((ms) => [...ms, assistantMsg])
let activeTools: Map<string, ToolCallResult> = new Map()
let receivedDone = false
const controller = streamChat(
text,
sessionId,
(ev: ChatEvent) => {
if (ev.type === 'session') return // sessionId is already known for a window
if (ev.type === 'tool_use') {
const tr: ToolCallResult = { type: 'tool_use', name: ev.data.name, id: ev.data.id, args: ev.data.args }
activeTools.set(ev.data.id, tr)
chat.messages.update((ms) => {
const last = ms[ms.length - 1]
if (last && last.role === 'assistant') last.tools = [...last.tools, tr]
return [...ms]
})
} else if (ev.type === 'tool_result') {
const existing = activeTools.get(ev.data.id)
if (existing) {
const updated: ToolCallResult = { ...existing, type: 'tool_result', result: ev.data.result, error: ev.data.error }
activeTools.set(ev.data.id, updated)
chat.messages.update((ms) => {
const last = ms[ms.length - 1]
if (last && last.role === 'assistant') {
last.tools = last.tools.map((t) => (t.id === ev.data.id ? updated : t))
last.pendingApprovals = extractApprovals(last.tools)
}
return [...ms]
})
}
} else if (ev.type === 'text_delta') {
chat.messages.update((ms) => {
const last = ms[ms.length - 1]
if (last && last.role === 'assistant') last.text += ev.data
return [...ms]
})
} else if (ev.type === 'text') {
chat.messages.update((ms) => {
const last = ms[ms.length - 1]
if (last && last.role === 'assistant') last.text = ev.data
return [...ms]
})
} else if (ev.type === 'done') {
receivedDone = true
chat.connectionState.set('connected')
chat.messages.update((ms) => {
const last = ms[ms.length - 1]
if (last && last.role === 'assistant') last.pendingApprovals = extractApprovals(last.tools)
return [...ms]
})
startSessionPolling(sessionId)
} else if (ev.type === 'error') {
chat.error.set(ev.data)
}
},
(err: string) => {
if (err === 'AbortError' || err.includes('aborted')) {
chat.streaming.set(false)
return
}
chat.error.set(err)
if (!receivedDone) {
chat.connectionState.set('disconnected')
startSessionPolling(sessionId)
addChatError('Agent connection lost. The task is still running — retrying…', 'Dismiss')
} else {
chat.streaming.set(false)
}
},
() => {
chat.streaming.set(false)
if (activeControllers.get(sessionId) === controller) activeControllers.delete(sessionId)
loadSessions()
}
)
activeControllers.set(sessionId, controller)
}
export function cancelSessionStream(sessionId: string) {
const controller = activeControllers.get(sessionId)
if (!controller) return
controller.abort()
activeControllers.delete(sessionId)
chatFor(sessionId).streaming.set(false)
}

View File

@@ -22,3 +22,20 @@ export function openEntityWindow(slug: string | null): void {
} }
wm.open({ id: slug, title: slug }) wm.open({ id: slug, title: slug })
} }
// Same dedupe/restore/focus pattern as openEntityWindow, for a task/session's
// chat window. Id is namespaced `session:<id>` — distinct from entity window
// ids (always a bare `type:identifier` slug, and task ENTITIES already use
// `task:<uuid>` as their own slug) so a task's chat window and its entity
// detail window never collide over the same wmkit id. See
// EntityDesktop.svelte for the id -> content-component branch.
export function openTaskWindow(sessionId: string | null, title: string): void {
if (!sessionId) return
const id = `session:${sessionId}`
if (wm.get(id)) {
wm.restore(id)
wm.focus(id)
return
}
wm.open({ id, title, width: 900, height: 640 })
}

View File

@@ -1,7 +1,7 @@
import { writable, derived, get } from 'svelte/store' import { writable, derived, get, type Writable, type Readable } from 'svelte/store'
import { liveEvents, subscribeEvents } from './events' import { liveEvents, subscribeEvents } from './events'
import { currentSession, sessions, loadSessions } from './chat' import { currentSession, sessions, loadSessions } from './chat'
import { fetchPlan, fetchQuestions, type PlanStep, type SessionQuestion } from '$lib/api' import { fetchPlan, fetchQuestions, type PlanStep, type SessionQuestion, type Session } from '$lib/api'
import type { import type {
PlanProposedData, PlanProposedData,
PlanStepEventData, PlanStepEventData,
@@ -21,16 +21,11 @@ import type {
// keeps updating across a tab reload: hydrate() re-fetches REST state, then // keeps updating across a tab reload: hydrate() re-fetches REST state, then
// live events carry deltas from there. // live events carry deltas from there.
export const planSteps = writable<PlanStep[]>([])
export const questions = writable<SessionQuestion[]>([])
export const openQuestion = derived(questions, (qs) => qs.find((q) => q.status === 'open') ?? null)
export interface TouchedEntity { export interface TouchedEntity {
slug: string slug: string
tool: string tool: string
ts: number ts: number
} }
export const touched = writable<TouchedEntity[]>([])
const TOUCHED_MAX = 12 const TOUCHED_MAX = 12
const TOUCHED_PULSE_MS = 6000 const TOUCHED_PULSE_MS = 6000
@@ -40,9 +35,35 @@ export interface HealthDiff {
to: string to: string
ts: number ts: number
} }
export const healthDiffs = writable<HealthDiff[]>([])
const HEALTH_DIFF_MS = 8000 const HEALTH_DIFF_MS = 8000
export interface WorkspaceState {
planSteps: Writable<PlanStep[]>
questions: Writable<SessionQuestion[]>
openQuestion: Readable<SessionQuestion | null>
touched: Writable<TouchedEntity[]>
healthDiffs: Writable<HealthDiff[]>
}
function createWorkspaceState(): WorkspaceState {
const questions = writable<SessionQuestion[]>([])
return {
planSteps: writable<PlanStep[]>([]),
questions,
openQuestion: derived(questions, (qs) => qs.find((q) => q.status === 'open') ?? null),
touched: writable<TouchedEntity[]>([]),
healthDiffs: writable<HealthDiff[]>([])
}
}
// ─── global "current session" workspace — used by the main Chat page's rail ─
const globalWorkspace = createWorkspaceState()
export const planSteps = globalWorkspace.planSteps
export const questions = globalWorkspace.questions
export const openQuestion = globalWorkspace.openQuestion
export const touched = globalWorkspace.touched
export const healthDiffs = globalWorkspace.healthDiffs
// The task's own fields (goal/status/outcome/summary) live on the session row. // The task's own fields (goal/status/outcome/summary) live on the session row.
// Rather than a dedicated endpoint, derive from the sessions list (already // Rather than a dedicated endpoint, derive from the sessions list (already
// fetched for the task board) and keep it fresh here on task-lifecycle events. // fetched for the task board) and keep it fresh here on task-lifecycle events.
@@ -50,33 +71,21 @@ export const currentTask = derived([sessions, currentSession], ([$sessions, $id]
$sessions.find((s) => s.id === $id) ?? null $sessions.find((s) => s.id === $id) ?? null
) )
// Events that can change agent_sessions.status/goal/outcome — see applyEvent. // Events that can change agent_sessions.status/goal/outcome — see applyEventTo.
const STATUS_AFFECTING = new Set([ const STATUS_AFFECTING = new Set([
'goal.set', 'task.status', 'plan.proposed', 'question.raised', 'question.answered' 'goal.set', 'task.status', 'plan.proposed', 'question.raised', 'question.answered'
]) ])
let hydratedFor: string | null = null
let unsubStream: (() => void) | null = null
let unsubLive: (() => void) | null = null
let refreshTimer: ReturnType<typeof setTimeout> | null = null let refreshTimer: ReturnType<typeof setTimeout> | null = null
let lastSeenId = 0 function scheduleSessionsRefresh() {
if (refreshTimer) clearTimeout(refreshTimer)
async function hydrate(sessionId: string) { refreshTimer = setTimeout(() => loadSessions(), 300)
hydratedFor = sessionId
planSteps.set([])
questions.set([])
touched.set([])
healthDiffs.set([])
const [steps, qs] = await Promise.all([fetchPlan(sessionId), fetchQuestions(sessionId)])
if (get(currentSession) !== sessionId) return // switched away while loading
planSteps.set(steps)
questions.set(qs)
} }
function applyPlanStepEvent(sessionId: string, type: string, data: PlanStepEventData) { function applyPlanStepEventTo(ws: WorkspaceState, data: PlanStepEventData) {
const stepID = data?.step_id const stepID = data?.step_id
const seq = data?.seq const seq = data?.seq
planSteps.update((steps) => { ws.planSteps.update((steps) => {
const i = steps.findIndex((s) => (stepID && s.id === stepID) || (seq != null && s.seq === seq)) const i = steps.findIndex((s) => (stepID && s.id === stepID) || (seq != null && s.seq === seq))
if (i === -1) return steps if (i === -1) return steps
const next = [...steps] const next = [...steps]
@@ -85,9 +94,11 @@ function applyPlanStepEvent(sessionId: string, type: string, data: PlanStepEvent
}) })
} }
function applyEvent(ev: { type: string; correlation_id?: string | null; data?: unknown }) { // Applies a live event to `ws` if it belongs to session `sid` — shared by the
const sid = get(currentSession) // global "current session" watcher and every per-session floating-window
if (!sid || ev.correlation_id !== sid) return // watcher, each passing its own target state and session id.
function applyEventTo(ws: WorkspaceState, sid: string, ev: { type: string; correlation_id?: string | null; data?: unknown }) {
if (ev.correlation_id !== sid) return
const data = (ev.data ?? {}) as Record<string, unknown> const data = (ev.data ?? {}) as Record<string, unknown>
// Task fields (status/goal/outcome) live on the session row — refetch the // Task fields (status/goal/outcome) live on the session row — refetch the
@@ -100,11 +111,9 @@ function applyEvent(ev: { type: string; correlation_id?: string | null; data?: u
// server-side, with no client-streaming 'done' event to piggyback a refresh // server-side, with no client-streaming 'done' event to piggyback a refresh
// on (found live: answering a question via the panel left the header stuck // on (found live: answering a question via the panel left the header stuck
// on "Needs your input" after the agent had already resumed). Debounced // on "Needs your input" after the agent had already resumed). Debounced
// since several of these can land in one burst. // since several of these can land in one burst, and shared across
if (STATUS_AFFECTING.has(ev.type)) { // sessions since it just refreshes the one global session list.
if (refreshTimer) clearTimeout(refreshTimer) if (STATUS_AFFECTING.has(ev.type)) scheduleSessionsRefresh()
refreshTimer = setTimeout(() => loadSessions(), 300)
}
switch (ev.type) { switch (ev.type) {
case 'plan.proposed': { case 'plan.proposed': {
@@ -114,17 +123,17 @@ function applyEvent(ev: { type: string; correlation_id?: string | null; data?: u
id: s.id, seq: s.seq, title: s.title, detail: s.detail ?? '', id: s.id, seq: s.seq, title: s.title, detail: s.detail ?? '',
status: 'pending' as const, target_slug: s.target_slug || undefined status: 'pending' as const, target_slug: s.target_slug || undefined
})) }))
planSteps.update((existing) => (d.appended ? [...existing, ...incoming] : incoming)) ws.planSteps.update((existing) => (d.appended ? [...existing, ...incoming] : incoming))
} }
break break
} }
case 'plan.step.started': case 'plan.step.started':
case 'plan.step.finished': case 'plan.step.finished':
applyPlanStepEvent(sid, ev.type, data as unknown as PlanStepEventData) applyPlanStepEventTo(ws, data as unknown as PlanStepEventData)
break break
case 'question.raised': { case 'question.raised': {
const d = data as unknown as QuestionRaisedData const d = data as unknown as QuestionRaisedData
questions.update((qs) => [ ws.questions.update((qs) => [
{ {
id: d.question_id, prompt: d.prompt ?? '', id: d.question_id, prompt: d.prompt ?? '',
context: { why: d.why, options: d.options, entities: d.entities }, context: { why: d.why, options: d.options, entities: d.entities },
@@ -136,7 +145,7 @@ function applyEvent(ev: { type: string; correlation_id?: string | null; data?: u
} }
case 'question.answered': { case 'question.answered': {
const d = data as unknown as QuestionAnsweredData const d = data as unknown as QuestionAnsweredData
questions.update((qs) => ws.questions.update((qs) =>
qs.map((q) => (q.id === d.question_id ? { ...q, status: 'answered', answer: d.answer } : q)) qs.map((q) => (q.id === d.question_id ? { ...q, status: 'answered', answer: d.answer } : q))
) )
break break
@@ -145,7 +154,7 @@ function applyEvent(ev: { type: string; correlation_id?: string | null; data?: u
const d = data as unknown as EntityTouchedData const d = data as unknown as EntityTouchedData
if (d.slug) { if (d.slug) {
const now = Date.now() const now = Date.now()
touched.update((t) => [{ slug: d.slug, tool: d.tool ?? '', ts: now }, ...t].slice(0, TOUCHED_MAX)) ws.touched.update((t) => [{ slug: d.slug, tool: d.tool ?? '', ts: now }, ...t].slice(0, TOUCHED_MAX))
} }
break break
} }
@@ -157,13 +166,30 @@ function applyEvent(ev: { type: string; correlation_id?: string | null; data?: u
// health.changed is task-agnostic (fleet-wide), so it's matched separately: // health.changed is task-agnostic (fleet-wide), so it's matched separately:
// show the diff whenever the changed entity is one this task has touched, not // show the diff whenever the changed entity is one this task has touched, not
// by correlation_id (health events don't carry one). // by correlation_id (health events don't carry one).
function applyHealthChanged(ev: { type: string; data?: unknown }) { function applyHealthChangedTo(ws: WorkspaceState, ev: { type: string; data?: unknown }) {
if (ev.type !== 'health.changed') return if (ev.type !== 'health.changed') return
const data = (ev.data ?? {}) as HealthChangedData const data = (ev.data ?? {}) as HealthChangedData
if (!data.slug) return if (!data.slug) return
const isRelevant = get(touched).some((t) => t.slug === data.slug) const isRelevant = get(ws.touched).some((t) => t.slug === data.slug)
if (!isRelevant) return if (!isRelevant) return
healthDiffs.update((d) => [{ slug: data.slug, from: data.from ?? '', to: data.to ?? '', ts: Date.now() }, ...d].slice(0, 8)) ws.healthDiffs.update((d) => [{ slug: data.slug, from: data.from ?? '', to: data.to ?? '', ts: Date.now() }, ...d].slice(0, 8))
}
let hydratedFor: string | null = null
let unsubStream: (() => void) | null = null
let unsubLive: (() => void) | null = null
let lastSeenId = 0
async function hydrate(sessionId: string) {
hydratedFor = sessionId
globalWorkspace.planSteps.set([])
globalWorkspace.questions.set([])
globalWorkspace.touched.set([])
globalWorkspace.healthDiffs.set([])
const [steps, qs] = await Promise.all([fetchPlan(sessionId), fetchQuestions(sessionId)])
if (get(currentSession) !== sessionId) return // switched away while loading
globalWorkspace.planSteps.set(steps)
globalWorkspace.questions.set(qs)
} }
// startWorkspace opens the global event subscription and begins tracking the // startWorkspace opens the global event subscription and begins tracking the
@@ -176,10 +202,10 @@ export function startWorkspace(): () => void {
if (sid && sid !== hydratedFor) hydrate(sid) if (sid && sid !== hydratedFor) hydrate(sid)
if (!sid) { if (!sid) {
hydratedFor = null hydratedFor = null
planSteps.set([]) globalWorkspace.planSteps.set([])
questions.set([]) globalWorkspace.questions.set([])
touched.set([]) globalWorkspace.touched.set([])
healthDiffs.set([]) globalWorkspace.healthDiffs.set([])
} }
}) })
@@ -191,11 +217,13 @@ export function startWorkspace(): () => void {
} }
const fresh = evs.filter((e) => e.id > lastSeenId) const fresh = evs.filter((e) => e.id > lastSeenId)
lastSeenId = maxId lastSeenId = maxId
const sid = get(currentSession)
if (!sid) return
// Oldest-first application so ordering (e.g. plan.step.started before // Oldest-first application so ordering (e.g. plan.step.started before
// .finished) is preserved. // .finished) is preserved.
for (const e of fresh.slice().reverse()) { for (const e of fresh.slice().reverse()) {
applyEvent(e) applyEventTo(globalWorkspace, sid, e)
applyHealthChanged(e) applyHealthChangedTo(globalWorkspace, e)
} }
}) })
@@ -206,9 +234,69 @@ export function startWorkspace(): () => void {
} }
} }
// Sweep expired pulses/diffs on an interval so old touches stop glowing. // ─── per-session workspace, for floating task windows ───────────────────────
//
// Same shape as the global workspace above, but keyed by session id instead
// of "whatever's on screen" — mirrors chat.ts's chatFor(). A window's
// TaskContextPanel calls startSessionWorkspace(sessionId) instead of
// startWorkspace(), and reads workspaceFor(sessionId)'s stores instead of the
// global ones, so several sessions' panels can be open and live at once.
const workspaces = new Map<string, WorkspaceState>()
export function workspaceFor(sessionId: string): WorkspaceState {
let w = workspaces.get(sessionId)
if (!w) {
w = createWorkspaceState()
workspaces.set(sessionId, w)
}
return w
}
export function taskFor(sessionId: string): Readable<Session | null> {
return derived(sessions, ($sessions) => $sessions.find((s) => s.id === sessionId) ?? null)
}
async function hydrateSession(ws: WorkspaceState, sessionId: string) {
const [steps, qs] = await Promise.all([fetchPlan(sessionId), fetchQuestions(sessionId)])
ws.planSteps.set(steps)
ws.questions.set(qs)
}
export function startSessionWorkspace(sessionId: string): () => void {
const ws = workspaceFor(sessionId)
const unsub = subscribeEvents()
hydrateSession(ws, sessionId)
// Own "seen" watermark rather than the global lastSeenId — several
// windows, each watching a different session, can be reading off the same
// liveEvents feed at once.
let lastSeen = 0
const unsubLive = liveEvents.subscribe((evs) => {
if (evs.length === 0) return
const maxId = evs[0].id
if (maxId <= lastSeen) return
const fresh = evs.filter((e) => e.id > lastSeen)
lastSeen = maxId
for (const e of fresh.slice().reverse()) {
applyEventTo(ws, sessionId, e)
applyHealthChangedTo(ws, e)
}
})
return () => {
unsubLive()
unsub()
}
}
// Sweep expired pulses/diffs on an interval so old touches stop glowing —
// across the global workspace and every per-session one currently in use.
setInterval(() => { setInterval(() => {
const now = Date.now() const now = Date.now()
touched.update((t) => t.filter((e) => now - e.ts < TOUCHED_PULSE_MS)) const sweep = (ws: WorkspaceState) => {
healthDiffs.update((d) => d.filter((e) => now - e.ts < HEALTH_DIFF_MS)) ws.touched.update((t) => t.filter((e) => now - e.ts < TOUCHED_PULSE_MS))
ws.healthDiffs.update((d) => d.filter((e) => now - e.ts < HEALTH_DIFF_MS))
}
sweep(globalWorkspace)
for (const ws of workspaces.values()) sweep(ws)
}, 1000) }, 1000)

View File

@@ -1,46 +1,10 @@
<script lang="ts"> <script lang="ts">
import { messages, streaming, connectionState, currentSession, sendMessage, cancelStream, reconnect, error, chatErrors, dismissError } from '$lib/stores/chat' import { messages, streaming, connectionState, sendMessage, cancelStream, reconnect, error, chatErrors, dismissError } from '$lib/stores/chat'
import { activityLog } from '$lib/stores/activity' import ChatThread from '$lib/components/ChatThread.svelte'
import TaskContextPanel from '$lib/components/TaskContextPanel.svelte' import TaskContextPanel from '$lib/components/TaskContextPanel.svelte'
import AgentIndicator from '$lib/components/AgentIndicator.svelte'
import { Button } from '$lib/components/ui/button'
import { Textarea } from '$lib/components/ui/textarea'
import ArrowUpIcon from '@lucide/svelte/icons/arrow-up'
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw'
import SquareIcon from '@lucide/svelte/icons/square'
import { marked } from 'marked'
import DOMPurify from 'dompurify'
let { showRail = true }: { showRail?: boolean } = $props() let { showRail = true }: { showRail?: boolean } = $props()
let input = $state('')
let messagesEnd = $state<HTMLDivElement | null>(null)
let scrolledUp = $state(false)
let container = $state<HTMLDivElement | null>(null)
function isNearBottom(): boolean {
if (!container) return true
const { scrollTop, scrollHeight, clientHeight } = container
return scrollHeight - scrollTop - clientHeight < 80
}
function onScroll() {
scrolledUp = !isNearBottom()
}
// Auto-scroll to bottom on new messages — unless user scrolled up to read.
$effect(() => {
void $messages
if ($streaming || !scrolledUp) {
setTimeout(() => messagesEnd?.scrollIntoView({ behavior: 'smooth' }), 50)
}
})
// Reset scroll lock when user sends a message.
function submitFollows() {
scrolledUp = false
}
// Resizable right rail (session graph). Persisted so it survives reloads. // Resizable right rail (session graph). Persisted so it survives reloads.
const RAIL_MIN = 260 const RAIL_MIN = 260
const RAIL_MAX = 620 const RAIL_MAX = 620
@@ -70,148 +34,27 @@
window.addEventListener('pointerup', up) window.addEventListener('pointerup', up)
} }
function render(text: string): string {
return DOMPurify.sanitize(marked.parse(text, { async: false }) as string)
}
function submit() {
const text = input.trim()
if (!text || $streaming) return
input = ''
scrolledUp = false
sendMessage(text)
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
submit()
}
}
const suggestions = [ const suggestions = [
'What needs my attention right now?', 'What needs my attention right now?',
'Summarize fleet health', 'Summarize fleet health',
'Any pending approvals or open signals?', 'Any pending approvals or open signals?',
'What changed in the last hour?' 'What changed in the last hour?'
] ]
function ask(q: string) {
if ($streaming) return
sendMessage(q)
}
</script> </script>
<div class="flex h-full min-h-0"> <div class="flex h-full min-h-0">
<div class="flex min-w-0 flex-1 flex-col"> <ChatThread
<div class="min-h-0 flex-1 overflow-y-auto" bind:this={container} onscroll={onScroll}> messages={$messages}
<div class="mx-auto flex max-w-3xl flex-col gap-5 p-4"> streaming={$streaming}
{#if $messages.length === 0} connectionState={$connectionState}
<div class="flex flex-col items-center gap-6 pt-24 text-center">
<div>
<h2 class="text-xl font-semibold">Nomos</h2>
<p class="mt-1 text-sm text-muted-foreground">Your resident operator. Ask about the fleet, or tell it to act.</p>
</div>
<div class="grid w-full max-w-md grid-cols-1 gap-2 sm:grid-cols-2">
{#each suggestions as q}
<Button variant="outline" size="sm" class="h-auto justify-start whitespace-normal py-2 text-left text-xs" onclick={() => ask(q)}>
{q}
</Button>
{/each}
</div>
</div>
{/if}
{#each $messages as msg, i (msg.id)}
<div class="flex flex-col gap-1.5 {msg.role === 'user' ? 'items-end' : 'items-start'}">
{#if msg.role === 'user'}
<div class="max-w-[85%] rounded-2xl rounded-br-sm bg-primary px-4 py-2.5 text-sm text-primary-foreground whitespace-pre-wrap user-msg">{msg.text}</div>
{:else}
<div class="flex w-full flex-col gap-2">
{#if msg.text}
<div class="prose-chat max-w-none text-sm leading-relaxed assistant-msg">
<!-- eslint-disable-next-line svelte/no-at-html-tags — sanitized via DOMPurify -->
{@html render(msg.text)}
</div>
{/if}
</div>
{/if}
</div>
{/each}
<AgentIndicator
active={$streaming || $activityLog.some((e) => e.status === 'running')}
lastActivity={$activityLog.find((e) => e.status === 'running') ?? null}
error={$error} error={$error}
chatErrors={$chatErrors}
onSend={sendMessage}
onCancel={cancelStream}
onReconnect={reconnect}
onDismissError={dismissError}
{suggestions}
/> />
<div bind:this={messagesEnd}></div>
</div>
</div>
{#if $connectionState === 'disconnected'}
<div class="mx-auto w-full max-w-3xl px-4">
<div class="mb-2 flex items-center gap-2 rounded-md border border-warning/50 bg-warning/10 px-3 py-2 text-xs">
<RefreshCwIcon class="size-3 shrink-0" aria-hidden="true" />
<span class="text-warning-foreground flex-1">Agent connection lost. The task may still be running.</span>
<Button size="xs" variant="outline" class="h-6 text-[11px]" onclick={reconnect}>Reconnect</Button>
</div>
</div>
{:else if $connectionState === 'reconnecting'}
<div class="mx-auto w-full max-w-3xl px-4">
<div class="mb-2 flex items-center gap-2 rounded-md border bg-muted/50 px-3 py-2 text-xs">
<RefreshCwIcon class="size-3 shrink-0 animate-spin text-muted-foreground" aria-hidden="true" />
<span class="text-muted-foreground flex-1">Reconnecting to agent…</span>
</div>
</div>
{/if}
{#if $error}
<div class="mx-auto w-full max-w-3xl px-4">
<div class="mb-2 rounded-md border border-destructive/50 bg-destructive/10 px-3 py-2 text-xs text-destructive">
{$error}
</div>
</div>
{/if}
{#each $chatErrors as err (err.id)}
<div class="mx-auto w-full max-w-3xl px-4">
<div class="mb-2 flex items-center gap-2 rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs text-destructive">
<span class="flex-1">{err.message}</span>
{#if err.action}
<Button size="xs" variant="ghost" class="h-6 text-[11px]" onclick={() => dismissError(err.id)}>{err.action}</Button>
{/if}
<button class="ml-1 text-muted-foreground hover:text-foreground" onclick={() => dismissError(err.id)} aria-label="Dismiss">×</button>
</div>
</div>
{/each}
<div class="border-t bg-card/50 p-3 input-ornament relative">
<form
class="mx-auto flex max-w-3xl items-end gap-2"
onsubmit={(e) => {
e.preventDefault()
submit()
}}
>
<Textarea
bind:value={input}
onkeydown={handleKeydown}
placeholder="Ask Nomos anything…"
rows={2}
class="max-h-40 min-h-0 resize-none"
disabled={$streaming}
/>
{#if $streaming}
<Button type="button" size="icon" variant="destructive" onclick={cancelStream} aria-label="Stop">
<SquareIcon />
</Button>
{:else}
<Button type="submit" size="icon" disabled={!input.trim()} aria-label="Send">
<ArrowUpIcon />
</Button>
{/if}
</form>
</div>
</div>
{#if showRail} {#if showRail}
<div class="hidden shrink-0 xl:flex" style="width: {railWidth}px"> <div class="hidden shrink-0 xl:flex" style="width: {railWidth}px">
@@ -233,180 +76,3 @@
</div> </div>
{/if} {/if}
</div> </div>
<style>
/* ── Art Nouveau chat styling ── */
/* Assistant message wrapper */
.assistant-msg {
position: relative;
}
/* User message — soft terracotta bubble, gentle lift */
.user-msg {
box-shadow: 0 1px 8px -4px var(--primary);
}
/* Prose overrides */
.prose-chat :global(p) {
margin: 0 0 0.5rem;
}
.prose-chat :global(p:last-child) {
margin-bottom: 0;
}
.prose-chat :global(ul),
.prose-chat :global(ol) {
margin: 0 0 0.5rem;
padding-left: 1.25rem;
}
.prose-chat :global(ul) {
list-style-type: disc;
}
.prose-chat :global(ol) {
list-style-type: decimal;
}
.prose-chat :global(li) {
margin-bottom: 0.125rem;
padding-left: 0.25rem;
}
.prose-chat :global(li::marker) {
color: var(--primary);
}
.prose-chat :global(code) {
background: var(--muted);
border: 1px solid var(--border);
border-radius: 4px;
padding: 0.15em 0.4em;
font-family: var(--font-mono);
font-size: 0.85em;
color: var(--primary);
}
.prose-chat :global(pre) {
background: var(--muted);
border: 1px solid var(--border);
border-radius: 8px;
padding: 0.75rem 0.875rem;
overflow-x: auto;
margin: 0 0 0.5rem;
position: relative;
}
.prose-chat :global(pre)::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 1px;
background: linear-gradient(to right, transparent, var(--primary), transparent);
opacity: 0.4;
}
.prose-chat :global(pre code) {
background: none;
padding: 0;
font-size: 0.8125rem;
color: inherit;
border: none;
}
/* Section headings — serif (Inknut) with a short accent rule. Extra top
margin separates sections; the first heading in a message doesn't. */
.prose-chat :global(h1),
.prose-chat :global(h2),
.prose-chat :global(h3) {
font-weight: 600;
margin: 1.15rem 0 0.4rem;
font-size: 1.03em;
letter-spacing: 0.01em;
position: relative;
display: inline-block;
}
.prose-chat :global(> h1:first-child),
.prose-chat :global(> h2:first-child),
.prose-chat :global(> h3:first-child) {
margin-top: 0;
}
.prose-chat :global(h1)::after,
.prose-chat :global(h2)::after,
.prose-chat :global(h3)::after {
content: '';
display: block;
width: 2.5rem;
height: 2px;
margin-top: 4px;
border-radius: 1px;
background: linear-gradient(to right, var(--primary), transparent);
opacity: 0.55;
}
.prose-chat :global(table) {
border-collapse: collapse;
margin: 0 0 0.5rem;
font-size: 0.8125rem;
}
.prose-chat :global(th) {
background: var(--muted);
font-weight: 600;
}
.prose-chat :global(th),
.prose-chat :global(td) {
border: 1px solid var(--border);
padding: 0.3rem 0.6rem;
text-align: left;
}
.prose-chat :global(blockquote) {
border-left: 3px solid var(--primary);
padding-left: 0.75rem;
color: var(--muted-foreground);
margin: 0 0 0.5rem;
font-style: italic;
position: relative;
}
.prose-chat :global(blockquote)::before {
content: '“';
position: absolute;
left: -0.15rem;
top: -0.35rem;
font-size: 1.5rem;
color: var(--primary);
opacity: 0.6;
font-style: normal;
line-height: 1;
}
.prose-chat :global(hr) {
border: none;
height: 1px;
margin: 0.75rem 0;
background: linear-gradient(to right, transparent, var(--border) 20%, var(--border) 80%, transparent);
}
/* Bold is emphasis, not color — dark weight reads cleanly and lets the
terracotta accent stay meaningful (code, headings, links). */
.prose-chat :global(strong) {
color: var(--foreground);
font-weight: 600;
}
.prose-chat :global(a) {
color: var(--primary);
text-decoration: underline;
text-decoration-style: dotted;
text-underline-offset: 2px;
}
/* Input area ornament */
.input-ornament::before {
content: '';
position: absolute;
top: 0;
left: 2rem;
right: 2rem;
height: 1px;
background: linear-gradient(to right, transparent, var(--primary), transparent);
opacity: 0.3;
}
</style>

View File

@@ -1,15 +1,14 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte' import { onMount } from 'svelte'
import { fetchEntities, fetchOntology, fetchGraph, type Entity, type EntityType, type Ontology } from '$lib/api' import { fetchAllEntities, fetchOntology, fetchGraph, type Entity, type EntityType, type Ontology } from '$lib/api'
import { liveEvents, subscribeEvents } from '$lib/stores/events' import { liveEvents, subscribeEvents } from '$lib/stores/events'
import EntityTable from '$lib/components/EntityTable.svelte' import EntityTable from '$lib/components/EntityTable.svelte'
import EntityGraph, { type GraphInfo } from '$lib/components/EntityGraph.svelte' import EntityGraph, { type GraphInfo } from '$lib/components/EntityGraph.svelte'
import MultiSelectFilter from '$lib/components/MultiSelectFilter.svelte' import MultiSelectFilter from '$lib/components/MultiSelectFilter.svelte'
import { categories, filtersForCategory, type Category } from '$lib/categories' import { typeToCategory, type Category } from '$lib/categories'
import { openEntityWindow, wmState } from '$lib/stores/windows' import { openEntityWindow, wmState } from '$lib/stores/windows'
import { Button } from '$lib/components/ui/button' import { Button } from '$lib/components/ui/button'
import { Input } from '$lib/components/ui/input' import { Input } from '$lib/components/ui/input'
import * as Select from '$lib/components/ui/select'
import { Switch } from '$lib/components/ui/switch' import { Switch } from '$lib/components/ui/switch'
import { Label } from '$lib/components/ui/label' import { Label } from '$lib/components/ui/label'
import NetworkIcon from '@lucide/svelte/icons/share-2' import NetworkIcon from '@lucide/svelte/icons/share-2'
@@ -23,8 +22,11 @@
return localStorage.getItem('oikos-kb-view') === 'table' ? 'table' : 'graph' return localStorage.getItem('oikos-kb-view') === 'table' ? 'table' : 'graph'
} }
let category = $state<Category>('fleet')
let view = $state<View>(loadView()) let view = $state<View>(loadView())
// Shared between table (filters rows) and graph (highlights/searches
// nodes) — one search box instead of two differently-labeled ones, since
// both views are asking the same underlying question ("show me X").
let search = $state('')
// Tracks only the most recently opened entity, for row/node highlight — // Tracks only the most recently opened entity, for row/node highlight —
// actual detail viewing now happens in floating windows (EntityDesktop), // actual detail viewing now happens in floating windows (EntityDesktop),
// which can have several entities open at once. // which can have several entities open at once.
@@ -47,33 +49,33 @@
if (lastOpened && !$wmState.windows[lastOpened]) lastOpened = null if (lastOpened && !$wmState.windows[lastOpened]) lastOpened = null
}) })
// ─── table data: fetched here (not inside EntityTable) so the search/type // ─── entities: fetched here (not inside EntityTable) so the search/type
// toolbar lives in the shared page toolbar instead of the resizable browse // 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 // pane, where its width is at the mercy of the divider and it would
// truncate. This also keeps the browse pane header-free, so it and the // truncate. Both views now share the same full entity set — there's no
// detail pane both start flush under the toolbar and end up the same height. // more per-category server-side scoping, only the client-side type
let tableEntities = $state<Entity[]>([]) // multiselect (activeTypes) below, which both the table (row visibility)
let tableLoading = $state(true) // and the graph (node visibility) read from.
let query = $state('') let allEntities = $state<Entity[]>([])
let typeFilter = $state('all') let entitiesLoading = $state(true)
let showInactive = $state(false) let showInactive = $state(false)
// child entity slug -> parent entity slug, derived from the ontology graph // child entity slug -> parent entity slug, derived from the ontology graph
// (see loadFleetGrouping). Only populated for the fleet category — feeds // (see loadGrouping). Feeds EntityTable's treegrid grouping, nesting e.g.
// EntityTable's treegrid grouping, nesting e.g. // host -> lxc -> service, or storage-pool -> volume -> dataset — computed
// cluster -> host -> lxc -> service, or storage-pool -> volume -> dataset. // over the whole entity set so the hierarchy doesn't reshuffle as the type
// filter is toggled (EntityTable falls back a filtered-out parent's
// children to top-level rather than dropping them).
let childToParent = $state<Map<string, string> | null>(null) let childToParent = $state<Map<string, string> | null>(null)
let ontologyCache: Promise<Ontology> | null = null let ontologyPromise: Promise<Ontology> | null = null
function getOntology(): Promise<Ontology> {
ontologyPromise ??= fetchOntology()
return ontologyPromise
}
function isOrDescendsFrom(byName: Map<string, EntityType>, typeName: string, ancestor: string): boolean { // type -> browsing category (see categories.ts), used only to seed the
if (typeName === ancestor) return true // type multiselect's default selection ("fleet") — not to scope any fetch.
let t = byName.get(typeName) let typeCategory = $state<Map<string, Category | undefined>>(new Map())
for (let depth = 0; t?.parent_type && depth < 10; depth++) {
if (t.parent_type === ancestor) return true
t = byName.get(t.parent_type)
}
return false
}
// Distance from the ontology's abstract root ("entity") down to typeName — // Distance from the ontology's abstract root ("entity") down to typeName —
// 0 for entity itself, 1 for its direct subtypes, etc. Used as a // 0 for entity itself, 1 for its direct subtypes, etc. Used as a
@@ -97,9 +99,9 @@
// parent (e.g. many hosts are located-at one site). many-to-many // parent (e.g. many hosts are located-at one site). many-to-many
// relationships (mounts, stores-on, backs-up-to, ...) have no single // relationships (mounts, stores-on, backs-up-to, ...) have no single
// parent, so they're excluded from tree nesting. A candidate parent that // parent, so they're excluded from tree nesting. A candidate parent that
// isn't actually part of the fleet set being browsed (e.g. `cluster`, // isn't actually part of the set being browsed (e.g. `cluster`, filtered
// filtered out below) is dropped rather than kept as a dangling pointer — // out below) is dropped rather than kept as a dangling pointer — that's
// that's also what lets `located-at` surface as a host's parent instead of // also what lets `located-at` surface as a host's parent instead of
// `member-of` without any special-cased priority: with cluster absent, // `member-of` without any special-cased priority: with cluster absent,
// member-of simply has nothing valid to point at. An entity can still be // member-of simply has nothing valid to point at. An entity can still be
// the child end of several different *remaining* relationship types at // the child end of several different *remaining* relationship types at
@@ -107,81 +109,82 @@
// repo) — only one can win as its tree parent, so ties go to the more // repo) — only one can win as its tree parent, so ties go to the more
// specific relationship (see typeDepth) rather than whichever was fetched // specific relationship (see typeDepth) rather than whichever was fetched
// last. // last.
async function loadFleetGrouping(fleetEntities: Entity[]): Promise<Map<string, string>> { async function loadGrouping(entities: Entity[]): Promise<Map<string, string>> {
ontologyCache ??= fetchOntology() const { entityTypes, relationshipTypes } = await getOntology()
const { entityTypes, relationshipTypes } = await ontologyCache
const byName = new Map(entityTypes.map((t) => [t.name, t])) const byName = new Map(entityTypes.map((t) => [t.name, t]))
const hierRels = relationshipTypes.filter((rt) => rt.cardinality !== 'many-to-many') const hierRels = relationshipTypes.filter((rt) => rt.cardinality !== 'many-to-many')
const relTypeNames = hierRels.map((rt) => rt.name) const relTypeNames = hierRels.map((rt) => rt.name)
const cardinalityByType = new Map(hierRels.map((rt) => [rt.name, rt.cardinality])) const cardinalityByType = new Map(hierRels.map((rt) => [rt.name, rt.cardinality]))
const specificityByType = new Map(hierRels.map((rt) => [rt.name, typeDepth(byName, rt.source_type)])) const specificityByType = new Map(hierRels.map((rt) => [rt.name, typeDepth(byName, rt.source_type)]))
const fleetSlugs = new Set(fleetEntities.map((e) => e.slug)) const slugs = new Set(entities.map((e) => e.slug))
// blast_radius only walks source -> target, so an entity only surfaces a // One whole-graph fetch instead of one rooted fetch per candidate parent
// relationship if it can be that relationship's source. // — now that grouping runs over the entire entity set rather than a
const roots = fleetEntities.filter((e) => // ~50-entity category, firing a request per entity blew past the
hierRels.some((rt) => isOrDescendsFrom(byName, e.type, rt.source_type)) // browser's concurrent-connection limit (ERR_INSUFFICIENT_RESOURCES).
) const g = await fetchGraph({ relType: relTypeNames })
const pairs = await Promise.all( const pairs = (g?.edges ?? [])
roots.map(async (root) => { .filter((edge) => cardinalityByType.has(edge.type) && slugs.has(edge.source) && slugs.has(edge.target))
const g = await fetchGraph({ root: root.slug, depth: 1, relType: relTypeNames })
return (g?.edges ?? [])
.filter((edge) => edge.source === root.slug && cardinalityByType.has(edge.type))
.map((edge) => { .map((edge) => {
const [child, parent] = const [child, parent] =
cardinalityByType.get(edge.type) === 'many-to-one' cardinalityByType.get(edge.type) === 'many-to-one'
? [edge.source, edge.target] // root is the child; target is the "one" (parent) ? [edge.source, edge.target] // source is the child; target is the "one" (parent)
: [edge.target, edge.source] // root is the "one" (parent); target is the child : [edge.target, edge.source] // source is the "one" (parent); target is the child
return { child, parent, weight: specificityByType.get(edge.type) ?? 0 } return { child, parent, weight: specificityByType.get(edge.type) ?? 0 }
}) })
.filter(({ parent }) => fleetSlugs.has(parent))
})
)
const best = new Map<string, { parent: string; weight: number }>() const best = new Map<string, { parent: string; weight: number }>()
for (const { child, parent, weight } of pairs.flat()) { for (const { child, parent, weight } of pairs) {
const current = best.get(child) const current = best.get(child)
if (!current || weight > current.weight) best.set(child, { parent, weight }) if (!current || weight > current.weight) best.set(child, { parent, weight })
} }
return new Map([...best].map(([child, { parent }]) => [child, parent])) return new Map([...best].map(([child, { parent }]) => [child, parent]))
} }
async function loadTable() { async function loadEntities() {
tableLoading = true entitiesLoading = true
const filterSets = filtersForCategory(category) // cluster entities are dropped so a host's `member-of` edge has no valid
const results = await Promise.all(filterSets.map((f) => fetchEntities(f))) // parent to point at, leaving `located-at` (site) as the only remaining
// cluster entities aren't shown in Fleet browsing — with them absent, a // tree-parent candidate (see loadGrouping).
// host's `member-of` edge has no valid parent to point at, so const fetched = (await fetchAllEntities()).filter((e) => e.type !== 'cluster')
// `located-at` (site) is the only remaining candidate and wins the childToParent = await loadGrouping(fetched)
// tree-parent tie-break without a hardcoded relationship priority (see allEntities = fetched
// loadFleetGrouping). entitiesLoading = false
const fetched = results.flat().filter((e) => e.type !== 'cluster')
childToParent = category === 'fleet' ? await loadFleetGrouping(fetched) : null
tableEntities = fetched
tableLoading = false
} }
onMount(() => { onMount(() => {
loadEntities()
getOntology().then((o) => {
typeCategory = new Map(o.entityTypes.map((t) => [t.name, typeToCategory(t.name, t.domain)]))
})
const unsubscribe = subscribeEvents() const unsubscribe = subscribeEvents()
return unsubscribe return unsubscribe
}) })
$effect(() => {
if (view !== 'table') return
category
loadTable()
})
$effect(() => { $effect(() => {
const ev = $liveEvents[0] const ev = $liveEvents[0]
if (view !== 'table' || !ev || !ev.type.startsWith('entity.')) return if (!ev || !ev.type.startsWith('entity.')) return
loadTable() loadEntities()
})
const allTypes = $derived(Array.from(new Set(allEntities.map((e) => e.type))).sort())
// Shared show/hide-by-type filter — governs both the table's row
// visibility and the graph's node visibility. Seeded once (not
// re-derived) to "fleet" types as soon as both the entity set and the
// ontology's type->category map are loaded, so it doesn't clobber the
// user's own toggles on a later reload.
let activeTypes = $state<Set<string>>(new Set())
let typesSeeded = false
$effect(() => {
if (typesSeeded || allTypes.length === 0 || typeCategory.size === 0) return
activeTypes = new Set(allTypes.filter((t) => typeCategory.get(t) === 'fleet'))
typesSeeded = true
}) })
const tableTypes = $derived(Array.from(new Set(tableEntities.map((e) => e.type))).sort())
const filteredEntities = $derived.by(() => { const filteredEntities = $derived.by(() => {
const q = query.trim().toLowerCase() const q = search.trim().toLowerCase()
return tableEntities.filter((e) => { return allEntities.filter((e) => {
if (typeFilter !== 'all' && e.type !== typeFilter) return false if (!activeTypes.has(e.type)) return false
if (q && !e.slug.toLowerCase().includes(q) && !e.name.toLowerCase().includes(q)) return false if (q && !e.slug.toLowerCase().includes(q) && !e.name.toLowerCase().includes(q)) return false
// entities with no tracked lifecycle state (state is null) aren't // entities with no tracked lifecycle state (state is null) aren't
// "destroyed or inactive" — only hide ones whose tracked state has // "destroyed or inactive" — only hide ones whose tracked state has
@@ -196,12 +199,10 @@
// width instead of being squeezed by the resizable browse pane. // width instead of being squeezed by the resizable browse pane.
let graphRoot = $state('') let graphRoot = $state('')
let graphDepth = $state(2) let graphDepth = $state(2)
let graphSearch = $state('')
let graphReloadToken = $state(0) let graphReloadToken = $state(0)
let graphResetToken = $state(0) let graphResetToken = $state(0)
let graphActiveNodeTypes = $state<Set<string>>(new Set())
let graphActiveRelTypes = $state<Set<string>>(new Set()) let graphActiveRelTypes = $state<Set<string>>(new Set())
let graphInfo = $state<GraphInfo>({ allNodeTypes: [], allRelTypes: [], relColors: new Map(), visibleCount: 0, truncated: false, zoomPct: 100 }) let graphInfo = $state<GraphInfo>({ allRelTypes: [], relColors: new Map(), visibleCount: 0, truncated: false, zoomPct: 100 })
function commitGraphQuery() { function commitGraphQuery() {
graphReloadToken++ graphReloadToken++
@@ -209,7 +210,7 @@
function resetGraph() { function resetGraph() {
graphRoot = '' graphRoot = ''
graphSearch = '' search = ''
graphResetToken++ graphResetToken++
} }
@@ -219,97 +220,76 @@
</script> </script>
<div class="flex h-full flex-col gap-3 p-4"> <div class="flex h-full flex-col gap-3 p-4">
<!-- toolbar: category perspective + view toggle --> <!-- single toolbar row: search + type filter are shared by both views
<div class="flex flex-wrap items-center gap-3"> (one multiselect instead of a category tab, a single-select "All
<div class="inline-flex overflow-hidden rounded-md border"> types" dropdown, and a separate graph node-type toggle), the rest is
{#each categories as c} view-specific, and the graph/table switch sits inline with the rest
<Button instead of floating in its own row. -->
variant={category === c.id ? 'secondary' : 'ghost'} <div class="flex flex-wrap items-center gap-2">
size="sm" <Input placeholder="Filter / highlight by slug or name…" bind:value={search} class="h-8 max-w-xs text-xs" />
class="h-8 rounded-none border-0" <MultiSelectFilter label="Types" options={allTypes} bind:selected={activeTypes} />
onclick={() => (category = c.id)}
> {#if view === 'table'}
{c.label} <div class="flex items-center gap-1.5">
</Button> <Switch id="show-inactive" bind:checked={showInactive} />
{/each} <Label for="show-inactive" class="text-xs font-normal text-muted-foreground">Inactive</Label>
</div> </div>
<span class="text-xs text-muted-foreground">{filteredEntities.length} of {allEntities.length}</span>
{:else}
<Input placeholder="Root entity…" bind:value={graphRoot} class="h-8 max-w-40 text-xs" onchange={commitGraphQuery} />
<Input type="number" min="1" max="5" bind:value={graphDepth} class="h-8 w-14 text-xs" onchange={commitGraphQuery} />
<Button variant="outline" size="sm" class="h-8" onclick={resetGraph}>
<LocateFixedIcon class="mr-1 size-3.5" />
Reset
</Button>
<MultiSelectFilter label="Edges" options={graphInfo.allRelTypes} bind:selected={graphActiveRelTypes} colorFor={relColorFor} />
<span class="text-xs text-muted-foreground">
{graphInfo.visibleCount} nodes{graphInfo.truncated ? ' · truncated' : ''} · {graphInfo.zoomPct}%
</span>
{/if}
<div class="ml-auto inline-flex overflow-hidden rounded-md border"> <div class="ml-auto inline-flex overflow-hidden rounded-md border">
<Button <Button
variant={view === 'graph' ? 'secondary' : 'ghost'} variant={view === 'graph' ? 'secondary' : 'ghost'}
size="sm" size="sm"
class="h-8 rounded-none border-0" class="h-8 rounded-none border-0 px-2"
onclick={() => setView('graph')} onclick={() => setView('graph')}
title="Graph view"
aria-label="Graph view"
> >
<NetworkIcon class="mr-1 size-3.5" /> Graph <NetworkIcon class="size-3.5" />
</Button> </Button>
<Button <Button
variant={view === 'table' ? 'secondary' : 'ghost'} variant={view === 'table' ? 'secondary' : 'ghost'}
size="sm" size="sm"
class="h-8 rounded-none border-0" class="h-8 rounded-none border-0 px-2"
onclick={() => setView('table')} onclick={() => setView('table')}
title="Table view"
aria-label="Table view"
> >
<TableIcon class="mr-1 size-3.5" /> Table <TableIcon class="size-3.5" />
</Button> </Button>
</div> </div>
</div> </div>
{#if view === 'table'}
<div class="flex flex-wrap items-center gap-2">
<Input placeholder="Filter by slug or name…" bind:value={query} class="h-8 max-w-xs text-xs" />
<Select.Root type="single" bind:value={typeFilter}>
<Select.Trigger class="h-8 w-40 text-xs">
{typeFilter === 'all' ? 'All types' : typeFilter}
</Select.Trigger>
<Select.Content>
<Select.Item value="all">All types</Select.Item>
{#each tableTypes as type}
<Select.Item value={type}>{type}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
<div class="flex items-center gap-1.5">
<Switch id="show-inactive" bind:checked={showInactive} />
<Label for="show-inactive" class="text-xs font-normal text-muted-foreground">Inactive</Label>
</div>
<span class="text-xs text-muted-foreground">{filteredEntities.length} of {tableEntities.length}</span>
</div>
{:else}
<div class="flex flex-wrap items-center gap-2">
<Input placeholder="Root entity…" bind:value={graphRoot} class="h-8 max-w-44 text-xs" onchange={commitGraphQuery} />
<Input type="number" min="1" max="5" bind:value={graphDepth} class="h-8 w-16 text-xs" onchange={commitGraphQuery} />
<Input placeholder="Search / highlight…" bind:value={graphSearch} class="h-8 max-w-44 text-xs" />
<Button variant="outline" size="sm" class="h-8" onclick={resetGraph}>
<LocateFixedIcon class="mr-1 size-3.5" />
Reset
</Button>
<MultiSelectFilter label="Nodes" options={graphInfo.allNodeTypes} bind:selected={graphActiveNodeTypes} />
<MultiSelectFilter label="Edges" options={graphInfo.allRelTypes} bind:selected={graphActiveRelTypes} colorFor={relColorFor} />
<span class="ml-auto text-xs text-muted-foreground">
{graphInfo.visibleCount} nodes{graphInfo.truncated ? ' · truncated' : ''} · {graphInfo.zoomPct}%
</span>
</div>
{/if}
<!-- browse pane — selecting an entity opens it in a floating window <!-- browse pane — selecting an entity opens it in a floating window
(EntityDesktop, mounted globally in App.svelte) instead of a sidebar. --> (EntityDesktop, mounted globally in App.svelte) instead of a sidebar. -->
<div class="flex min-h-0 min-w-0 flex-1 flex-col"> <div class="flex min-h-0 min-w-0 flex-1 flex-col">
{#if view === 'graph'} {#if view === 'graph'}
<EntityGraph <EntityGraph
{category}
selectedSlug={lastOpened} selectedSlug={lastOpened}
onSelect={select} onSelect={select}
bind:root={graphRoot} bind:root={graphRoot}
depth={graphDepth} depth={graphDepth}
search={graphSearch} {search}
reloadToken={graphReloadToken} reloadToken={graphReloadToken}
resetToken={graphResetToken} resetToken={graphResetToken}
bind:activeNodeTypes={graphActiveNodeTypes} activeNodeTypes={activeTypes}
bind:activeRelTypes={graphActiveRelTypes} bind:activeRelTypes={graphActiveRelTypes}
bind:info={graphInfo} bind:info={graphInfo}
/> />
{:else} {:else}
<EntityTable entities={filteredEntities} loading={tableLoading} selectedSlug={lastOpened} onSelect={select} {childToParent} /> <EntityTable entities={filteredEntities} loading={entitiesLoading} selectedSlug={lastOpened} onSelect={select} {childToParent} />
{/if} {/if}
</div> </div>
</div> </div>

View File

@@ -1,7 +1,8 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte' import { onMount } from 'svelte'
import { fetchDashboardSummary, type DashboardSummary } from '$lib/api' import { fetchDashboardSummary, type DashboardSummary, type Session } from '$lib/api'
import { sessions, loadSessions, loadSessionMessages, newChat, sendMessage } from '$lib/stores/chat' import { sessions, loadSessions, newChat, sendMessage } from '$lib/stores/chat'
import { openTaskWindow } from '$lib/stores/windows'
import { liveEvents, subscribeEvents } from '$lib/stores/events' import { liveEvents, subscribeEvents } from '$lib/stores/events'
import { bucket, statusStyle, FILTERS, TASK_EVENTS, heading, type Bucket } from '$lib/tasks' import { bucket, statusStyle, FILTERS, TASK_EVENTS, heading, type Bucket } from '$lib/tasks'
import { relativeTime } from '$lib/utils' import { relativeTime } from '$lib/utils'
@@ -54,9 +55,8 @@
filter === 'all' ? $sessions : $sessions.filter((s) => bucket(s) === filter) filter === 'all' ? $sessions : $sessions.filter((s) => bucket(s) === filter)
) )
function openTask(id: string) { function openTask(s: Session) {
loadSessionMessages(id) openTaskWindow(s.id, heading(s))
location.hash = '#/chat'
} }
// ── New task entry ────────────────────────────────────────────────────── // ── New task entry ──────────────────────────────────────────────────────
@@ -233,7 +233,7 @@
{@const st = statusStyle(s)} {@const st = statusStyle(s)}
<tr <tr
class="cursor-pointer border-b last:border-0 transition-colors hover:bg-muted/40" class="cursor-pointer border-b last:border-0 transition-colors hover:bg-muted/40"
onclick={() => openTask(s.id)} onclick={() => openTask(s)}
> >
<td class="px-4 py-2.5"> <td class="px-4 py-2.5">
<span class="flex items-center gap-1.5 text-xs font-medium text-muted-foreground"> <span class="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">