Compare commits
3 Commits
544afae77f
...
8657ac5669
| Author | SHA1 | Date | |
|---|---|---|---|
| 8657ac5669 | |||
| e28e0e9ea3 | |||
| 6051fb4845 |
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 }))
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
377
web/src/lib/components/ChatThread.svelte
Normal file
377
web/src/lib/components/ChatThread.svelte
Normal 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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
36
web/src/lib/components/MinimizedWindowsBar.svelte
Normal file
36
web/src/lib/components/MinimizedWindowsBar.svelte
Normal 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}
|
||||||
@@ -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" />
|
||||||
|
|||||||
91
web/src/lib/components/SessionChatWindow.svelte
Normal file
91
web/src/lib/components/SessionChatWindow.svelte
Normal 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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 ?? {}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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>
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user