feat(web): open tasks/sessions as floating windows with independent live chat
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
ci / web (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled

Clicking a task now opens it as a wmkit floating window (like entity
windows already do) instead of navigating away from wherever you were.
Several task windows can be open and actively streaming at once, each
fully independent — no "which one's on screen" guard needed, since
each window owns its own store bundle:

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-19 17:00:41 +02:00
parent e28e0e9ea3
commit 8657ac5669
14 changed files with 945 additions and 448 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,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">