feat(web): open tasks/sessions as floating windows with independent live chat
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:
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { activityLog, type ActivityEntry } from '$lib/stores/activity'
|
||||
import type { ActivityEntry } from '$lib/stores/activity'
|
||||
import Spinner from './Spinner.svelte'
|
||||
import CircleDotIcon from '@lucide/svelte/icons/circle-dot'
|
||||
import CircleXIcon from '@lucide/svelte/icons/circle-x'
|
||||
@@ -11,6 +11,9 @@
|
||||
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down'
|
||||
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>())
|
||||
|
||||
function toggle(id: string) {
|
||||
@@ -56,7 +59,7 @@
|
||||
|
||||
<div class="flex h-full flex-col">
|
||||
<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">
|
||||
<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" />
|
||||
@@ -78,8 +81,8 @@
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-col py-1">
|
||||
{#each $activityLog as entry, i (entry.id)}
|
||||
{@const isLast = i === $activityLog.length - 1}
|
||||
{#each entries as entry, i (entry.id)}
|
||||
{@const isLast = i === entries.length - 1}
|
||||
{@const icon = typeIcon(entry.type)}
|
||||
{@const isOpen = expanded.has(entry.id)}
|
||||
{@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
|
||||
// page, so an entity opened from Knowledge Base, chat, or anywhere else
|
||||
// lands in the same window stack instead of each page owning its own
|
||||
// single-entity sidebar/sheet. See $lib/stores/windows.ts.
|
||||
// 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 EntityDetailContent from './EntityDetailContent.svelte'
|
||||
import SessionChatWindow from './SessionChatWindow.svelte'
|
||||
import XIcon from '@lucide/svelte/icons/x'
|
||||
import MinusIcon from '@lucide/svelte/icons/minus'
|
||||
|
||||
const SESSION_PREFIX = 'session:'
|
||||
</script>
|
||||
|
||||
<div use:dk.desktop class="pointer-events-none fixed inset-0 z-40">
|
||||
@@ -15,12 +21,12 @@
|
||||
{#if win}
|
||||
<section use:dk.window={{ id }} class="min-w-0" aria-label={win.title}>
|
||||
<header data-wm-drag class="flex shrink-0 cursor-move items-center justify-between gap-2 border-b bg-muted/40 px-3 py-1.5">
|
||||
<span data-wm-title class="min-w-0 flex-1 truncate font-mono text-xs font-medium">{win.title}</span>
|
||||
<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">
|
||||
<button
|
||||
type="button"
|
||||
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}"
|
||||
>
|
||||
<MinusIcon class="size-3.5" />
|
||||
@@ -28,7 +34,7 @@
|
||||
<button
|
||||
type="button"
|
||||
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}"
|
||||
>
|
||||
<XIcon class="size-3.5" />
|
||||
@@ -37,7 +43,11 @@
|
||||
</header>
|
||||
<div data-wm-content class="min-h-0 flex-1 overflow-hidden">
|
||||
{#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}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
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">
|
||||
import { openQuestion } from '$lib/stores/workspace'
|
||||
import { currentSession } from '$lib/stores/chat'
|
||||
import { answerQuestion as postAnswer } from '$lib/api'
|
||||
import { answerQuestion as postAnswer, type SessionQuestion } from '$lib/api'
|
||||
import { Button } from '$lib/components/ui/button'
|
||||
import { Textarea } from '$lib/components/ui/textarea'
|
||||
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 submitting = $state(false)
|
||||
|
||||
async function submit(answer: string) {
|
||||
const sid = $currentSession
|
||||
const q = $openQuestion
|
||||
const sid = sessionId
|
||||
const q = question
|
||||
if (!sid || !q || !answer.trim() || submitting) return
|
||||
submitting = true
|
||||
const ok = await postAnswer(sid, q.id, answer.trim())
|
||||
@@ -23,8 +24,8 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if $openQuestion}
|
||||
{@const q = $openQuestion}
|
||||
{#if question}
|
||||
{@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 items-start gap-2">
|
||||
<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>
|
||||
@@ -1,8 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte'
|
||||
import { startWorkspace, planSteps, currentTask, touched } from '$lib/stores/workspace'
|
||||
import { activityLog } from '$lib/stores/activity'
|
||||
import { streaming } from '$lib/stores/chat'
|
||||
import { startWorkspace, startSessionWorkspace, planSteps, currentTask, openQuestion, touched, healthDiffs, workspaceFor, taskFor } from '$lib/stores/workspace'
|
||||
import { streaming, messages, currentSession, chatFor } from '$lib/stores/chat'
|
||||
import { activityLog, activityLogFor } from '$lib/stores/activity'
|
||||
import OperatorQuestion from './OperatorQuestion.svelte'
|
||||
import SessionGraph from './SessionGraph.svelte'
|
||||
import ActivityTimeline from './ActivityTimeline.svelte'
|
||||
@@ -16,7 +16,29 @@
|
||||
import CircleSlashIcon from '@lucide/svelte/icons/circle-slash'
|
||||
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 planOpen = $state(true)
|
||||
@@ -58,8 +80,8 @@
|
||||
}
|
||||
|
||||
// Plan collapsed status
|
||||
const planDone = $derived($planSteps.filter((s) => s.status === 'done').length)
|
||||
const planTotal = $derived($planSteps.length)
|
||||
const planDone = $derived($planStepsStore.filter((s) => s.status === 'done').length)
|
||||
const planTotal = $derived($planStepsStore.length)
|
||||
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
|
||||
@@ -67,19 +89,19 @@
|
||||
// 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.
|
||||
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 === 'planning' || $streaming) return 'drafting'
|
||||
if (st === 'planning' || $streamingStore) return 'drafting'
|
||||
return 'idle'
|
||||
})
|
||||
|
||||
// Activity collapsed status
|
||||
const activityRunning = $derived($activityLog.filter((e) => e.status === 'running').length)
|
||||
const activityCount = $derived($activityLog.length)
|
||||
const activityRunning = $derived($activityLogStore.filter((e) => e.status === 'running').length)
|
||||
const activityCount = $derived($activityLogStore.length)
|
||||
</script>
|
||||
|
||||
<div class="flex h-full min-h-0 flex-col">
|
||||
<OperatorQuestion />
|
||||
<OperatorQuestion sessionId={effectiveSessionId} question={$openQuestionStore} />
|
||||
|
||||
<!-- Scope -->
|
||||
<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}
|
||||
<span>Scope</span>
|
||||
{#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}
|
||||
</button>
|
||||
{#if scopeOpen}
|
||||
<div style="height: {heights[0]}px">
|
||||
<SessionGraph />
|
||||
<SessionGraph messages={$messagesStore} touched={$touchedStore} healthDiffs={$healthDiffsStore} />
|
||||
</div>
|
||||
<!-- resize handle -->
|
||||
<div
|
||||
@@ -120,8 +142,8 @@
|
||||
{#if !planOpen}
|
||||
{#if planTotal > 0}
|
||||
<span class="ml-auto font-normal normal-case">Step {planDone}/{planTotal}</span>
|
||||
{:else if $currentTask?.goal}
|
||||
<span class="ml-auto max-w-[120px] truncate font-normal normal-case">{$currentTask.goal}</span>
|
||||
{:else if $taskStore?.goal}
|
||||
<span class="ml-auto max-w-[120px] truncate font-normal normal-case">{$taskStore.goal}</span>
|
||||
{:else}
|
||||
<span class="ml-auto font-normal normal-case text-muted-foreground">No plan yet</span>
|
||||
{/if}
|
||||
@@ -129,10 +151,10 @@
|
||||
</button>
|
||||
{#if planOpen}
|
||||
<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">
|
||||
<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>
|
||||
{/if}
|
||||
{#if planTotal > 0}
|
||||
@@ -146,11 +168,11 @@
|
||||
</div>
|
||||
</div>
|
||||
<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 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' : ''}">
|
||||
{#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>
|
||||
{/if}
|
||||
<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}
|
||||
<span>Event log</span>
|
||||
{#if !activityOpen}
|
||||
{#if $streaming && activityRunning > 0}
|
||||
{#if $streamingStore && activityRunning > 0}
|
||||
<Spinner class="size-3 text-primary" />
|
||||
<span class="font-normal normal-case text-primary">{activityRunning} running</span>
|
||||
{:else}
|
||||
@@ -252,7 +274,7 @@
|
||||
</button>
|
||||
{#if activityOpen}
|
||||
<div class="min-h-0 flex-1 overflow-hidden">
|
||||
<ActivityTimeline />
|
||||
<ActivityTimeline entries={$activityLogStore} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user