diff --git a/web/src/lib/components/SessionChatWindow.svelte b/web/src/lib/components/SessionChatWindow.svelte
new file mode 100644
index 0000000..8189bd9
--- /dev/null
+++ b/web/src/lib/components/SessionChatWindow.svelte
@@ -0,0 +1,91 @@
+
+
+
diff --git a/web/src/lib/components/TaskContextPanel.svelte b/web/src/lib/components/TaskContextPanel.svelte
index a149cfc..22c1b05 100644
--- a/web/src/lib/components/TaskContextPanel.svelte
+++ b/web/src/lib/components/TaskContextPanel.svelte
@@ -1,8 +1,8 @@
-
+
@@ -91,12 +113,12 @@
{#if scopeOpen}
{:else}
{/if}
Scope
{#if !scopeOpen}
-
{$touched.length ? `${$touched.length} entit${$touched.length === 1 ? 'y' : 'ies'}` : 'Graph'}
+
{$touchedStore.length ? `${$touchedStore.length} entit${$touchedStore.length === 1 ? 'y' : 'ies'}` : 'Graph'}
{/if}
{#if scopeOpen}
-
+
0}
Step {planDone}/{planTotal}
- {:else if $currentTask?.goal}
-
{$currentTask.goal}
+ {:else if $taskStore?.goal}
+
{$taskStore.goal}
{:else}
No plan yet
{/if}
@@ -129,10 +151,10 @@
{#if planOpen}
- {#if $currentTask?.goal}
+ {#if $taskStore?.goal}
- {$currentTask.goal}
+ {$taskStore.goal}
{/if}
{#if planTotal > 0}
@@ -146,11 +168,11 @@
- {#each $planSteps as step, i (step.id)}
+ {#each $planStepsStore as step, i (step.id)}
{@const isDone = step.status === 'done'}
{@const isRunning = step.status === 'running'}
- {#if i < $planSteps.length - 1}
+ {#if i < $planStepsStore.length - 1}
{/if}
@@ -242,7 +264,7 @@
{#if activityOpen} {:else} {/if}
Event log
{#if !activityOpen}
- {#if $streaming && activityRunning > 0}
+ {#if $streamingStore && activityRunning > 0}
{activityRunning} running
{:else}
@@ -252,7 +274,7 @@
{#if activityOpen}
{/if}
diff --git a/web/src/lib/stores/activity.ts b/web/src/lib/stores/activity.ts
index 058e7f8..73c78ed 100644
--- a/web/src/lib/stores/activity.ts
+++ b/web/src/lib/stores/activity.ts
@@ -1,6 +1,7 @@
-import { derived } from 'svelte/store'
-import { messages, type ToolCallResult } from './chat'
-import { planSteps, currentTask } from './workspace'
+import { derived, type Readable } from 'svelte/store'
+import { messages, chatFor, type ChatMessage, type ToolCallResult } from './chat'
+import { planSteps, currentTask, workspaceFor, taskFor } from './workspace'
+import type { PlanStep, Session } from '$lib/api'
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
}
-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 now = Date.now()
@@ -168,7 +172,18 @@ export const activityLog = derived([messages, planSteps, currentTask], ([$msgs,
entries.sort((a, b) => a.timestamp - b.timestamp)
return entries
-})
+}
+
+export const activityLog = derived([messages, planSteps, currentTask], ([$msgs, $steps, $task]) =>
+ computeActivityLog($msgs, $steps, $task)
+)
+
+export function activityLogFor(sessionId: string): Readable
{
+ 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 {
const args = t.args ?? {}
diff --git a/web/src/lib/stores/chat.ts b/web/src/lib/stores/chat.ts
index 3039c6f..960a3ab 100644
--- a/web/src/lib/stores/chat.ts
+++ b/web/src/lib/stores/chat.ts
@@ -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 type { ChatEvent, Session, Message } from '$lib/api'
import type { ToolCallResult } from '$lib/types'
@@ -481,3 +481,172 @@ export async function deleteSession(sessionId: string) {
}
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
+ streaming: Writable
+ connectionState: Writable<'connected' | 'disconnected' | 'reconnecting'>
+ error: Writable
+}
+
+const sessionChats = new Map()
+const sessionPollers = new Map>()
+
+// 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 {
+ 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 = 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)
+}
diff --git a/web/src/lib/stores/windows.ts b/web/src/lib/stores/windows.ts
index b6339a4..647dd73 100644
--- a/web/src/lib/stores/windows.ts
+++ b/web/src/lib/stores/windows.ts
@@ -22,3 +22,20 @@ export function openEntityWindow(slug: string | null): void {
}
wm.open({ id: slug, title: slug })
}
+
+// Same dedupe/restore/focus pattern as openEntityWindow, for a task/session's
+// chat window. Id is namespaced `session:` — distinct from entity window
+// ids (always a bare `type:identifier` slug, and task ENTITIES already use
+// `task:` 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 })
+}
diff --git a/web/src/lib/stores/workspace.ts b/web/src/lib/stores/workspace.ts
index 4f37c30..3e45b61 100644
--- a/web/src/lib/stores/workspace.ts
+++ b/web/src/lib/stores/workspace.ts
@@ -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 { 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 {
PlanProposedData,
PlanStepEventData,
@@ -21,16 +21,11 @@ import type {
// keeps updating across a tab reload: hydrate() re-fetches REST state, then
// live events carry deltas from there.
-export const planSteps = writable([])
-export const questions = writable([])
-export const openQuestion = derived(questions, (qs) => qs.find((q) => q.status === 'open') ?? null)
-
export interface TouchedEntity {
slug: string
tool: string
ts: number
}
-export const touched = writable([])
const TOUCHED_MAX = 12
const TOUCHED_PULSE_MS = 6000
@@ -40,9 +35,35 @@ export interface HealthDiff {
to: string
ts: number
}
-export const healthDiffs = writable([])
const HEALTH_DIFF_MS = 8000
+export interface WorkspaceState {
+ planSteps: Writable
+ questions: Writable
+ openQuestion: Readable
+ touched: Writable
+ healthDiffs: Writable
+}
+
+function createWorkspaceState(): WorkspaceState {
+ const questions = writable([])
+ return {
+ planSteps: writable([]),
+ questions,
+ openQuestion: derived(questions, (qs) => qs.find((q) => q.status === 'open') ?? null),
+ touched: writable([]),
+ healthDiffs: writable([])
+ }
+}
+
+// ─── 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.
// 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.
@@ -50,33 +71,21 @@ export const currentTask = derived([sessions, currentSession], ([$sessions, $id]
$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([
'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 | null = null
-let lastSeenId = 0
-
-async function hydrate(sessionId: string) {
- 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 scheduleSessionsRefresh() {
+ if (refreshTimer) clearTimeout(refreshTimer)
+ refreshTimer = setTimeout(() => loadSessions(), 300)
}
-function applyPlanStepEvent(sessionId: string, type: string, data: PlanStepEventData) {
+function applyPlanStepEventTo(ws: WorkspaceState, data: PlanStepEventData) {
const stepID = data?.step_id
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))
if (i === -1) return 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 }) {
- const sid = get(currentSession)
- if (!sid || ev.correlation_id !== sid) return
+// Applies a live event to `ws` if it belongs to session `sid` — shared by the
+// global "current session" watcher and every per-session floating-window
+// 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
// 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
// on (found live: answering a question via the panel left the header stuck
// on "Needs your input" after the agent had already resumed). Debounced
- // since several of these can land in one burst.
- if (STATUS_AFFECTING.has(ev.type)) {
- if (refreshTimer) clearTimeout(refreshTimer)
- refreshTimer = setTimeout(() => loadSessions(), 300)
- }
+ // since several of these can land in one burst, and shared across
+ // sessions since it just refreshes the one global session list.
+ if (STATUS_AFFECTING.has(ev.type)) scheduleSessionsRefresh()
switch (ev.type) {
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 ?? '',
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
}
case 'plan.step.started':
case 'plan.step.finished':
- applyPlanStepEvent(sid, ev.type, data as unknown as PlanStepEventData)
+ applyPlanStepEventTo(ws, data as unknown as PlanStepEventData)
break
case 'question.raised': {
const d = data as unknown as QuestionRaisedData
- questions.update((qs) => [
+ ws.questions.update((qs) => [
{
id: d.question_id, prompt: d.prompt ?? '',
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': {
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))
)
break
@@ -145,7 +154,7 @@ function applyEvent(ev: { type: string; correlation_id?: string | null; data?: u
const d = data as unknown as EntityTouchedData
if (d.slug) {
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
}
@@ -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:
// show the diff whenever the changed entity is one this task has touched, not
// 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
const data = (ev.data ?? {}) as HealthChangedData
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
- 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
@@ -176,10 +202,10 @@ export function startWorkspace(): () => void {
if (sid && sid !== hydratedFor) hydrate(sid)
if (!sid) {
hydratedFor = null
- planSteps.set([])
- questions.set([])
- touched.set([])
- healthDiffs.set([])
+ globalWorkspace.planSteps.set([])
+ globalWorkspace.questions.set([])
+ globalWorkspace.touched.set([])
+ globalWorkspace.healthDiffs.set([])
}
})
@@ -191,11 +217,13 @@ export function startWorkspace(): () => void {
}
const fresh = evs.filter((e) => e.id > lastSeenId)
lastSeenId = maxId
+ const sid = get(currentSession)
+ if (!sid) return
// Oldest-first application so ordering (e.g. plan.step.started before
// .finished) is preserved.
for (const e of fresh.slice().reverse()) {
- applyEvent(e)
- applyHealthChanged(e)
+ applyEventTo(globalWorkspace, sid, 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()
+
+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 {
+ 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(() => {
const now = Date.now()
- touched.update((t) => t.filter((e) => now - e.ts < TOUCHED_PULSE_MS))
- healthDiffs.update((d) => d.filter((e) => now - e.ts < HEALTH_DIFF_MS))
+ const sweep = (ws: WorkspaceState) => {
+ 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)
diff --git a/web/src/pages/Chat.svelte b/web/src/pages/Chat.svelte
index ae03af3..1ed0601 100644
--- a/web/src/pages/Chat.svelte
+++ b/web/src/pages/Chat.svelte
@@ -1,46 +1,10 @@
-
-
-
- {#if $messages.length === 0}
-
-
-
Nomos
-
Your resident operator. Ask about the fleet, or tell it to act.
-
-
- {#each suggestions as q}
- ask(q)}>
- {q}
-
- {/each}
-
-
- {/if}
-
- {#each $messages as msg, i (msg.id)}
-
- {#if msg.role === 'user'}
-
{msg.text}
- {:else}
-
- {#if msg.text}
-
-
- {@html render(msg.text)}
-
- {/if}
-
- {/if}
-
- {/each}
-
e.status === 'running')}
- lastActivity={$activityLog.find((e) => e.status === 'running') ?? null}
- error={$error}
- />
-
-
-
-
- {#if $connectionState === 'disconnected'}
-
-
-
- Agent connection lost. The task may still be running.
- Reconnect
-
-
- {:else if $connectionState === 'reconnecting'}
-
-
-
- Reconnecting to agent…
-
-
- {/if}
-
- {#if $error}
-
- {/if}
-
- {#each $chatErrors as err (err.id)}
-
-
- {err.message}
- {#if err.action}
- dismissError(err.id)}>{err.action}
- {/if}
- dismissError(err.id)} aria-label="Dismiss">×
-
-
- {/each}
-
-
-
+
{#if showRail}
@@ -233,180 +76,3 @@
{/if}
-
-
diff --git a/web/src/pages/Overview.svelte b/web/src/pages/Overview.svelte
index 5a073f9..84d1166 100644
--- a/web/src/pages/Overview.svelte
+++ b/web/src/pages/Overview.svelte
@@ -1,7 +1,8 @@