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,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<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 {
|
||||
const args = t.args ?? {}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { writable, get } from 'svelte/store'
|
||||
import { writable, get, type Writable } from 'svelte/store'
|
||||
import { streamChat, fetchSessions, fetchMessages, deleteSession as apiDeleteSession } from '$lib/api'
|
||||
import 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<ChatMessage[]>
|
||||
streaming: Writable<boolean>
|
||||
connectionState: Writable<'connected' | 'disconnected' | 'reconnecting'>
|
||||
error: Writable<string | null>
|
||||
}
|
||||
|
||||
const sessionChats = new Map<string, SessionChatState>()
|
||||
const sessionPollers = new Map<string, ReturnType<typeof setInterval>>()
|
||||
|
||||
// Lazily creates (and memoizes) the store bundle for a session — call this to
|
||||
// get the stores to subscribe to; it does not fetch anything.
|
||||
export function chatFor(sessionId: string): SessionChatState {
|
||||
let c = sessionChats.get(sessionId)
|
||||
if (!c) {
|
||||
c = { messages: writable([]), streaming: writable(false), connectionState: writable('connected'), error: writable(null) }
|
||||
sessionChats.set(sessionId, c)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
function startSessionPolling(sessionId: string) {
|
||||
const existing = sessionPollers.get(sessionId)
|
||||
if (existing) clearInterval(existing)
|
||||
const chat = chatFor(sessionId)
|
||||
sessionPollers.set(
|
||||
sessionId,
|
||||
setInterval(async () => {
|
||||
if (get(chat.streaming) && get(chat.connectionState) === 'connected') return
|
||||
const msgs = await fetchMessages(sessionId)
|
||||
if (get(chat.streaming)) return // re-check: the fetch itself takes time
|
||||
chat.messages.set(toChatMessages(msgs))
|
||||
}, 3000)
|
||||
)
|
||||
}
|
||||
|
||||
export function stopSessionPolling(sessionId: string) {
|
||||
const t = sessionPollers.get(sessionId)
|
||||
if (t) {
|
||||
clearInterval(t)
|
||||
sessionPollers.delete(sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
// Fetches sessionId's current transcript into its own store bundle and
|
||||
// starts polling it for auto-continuation updates — the per-session
|
||||
// equivalent of loadSessionMessages, for a window rather than the main view.
|
||||
export async function loadSessionChat(sessionId: string): Promise<void> {
|
||||
const chat = chatFor(sessionId)
|
||||
chat.streaming.set(false)
|
||||
const msgs = await fetchMessages(sessionId)
|
||||
chat.messages.set(toChatMessages(msgs))
|
||||
startSessionPolling(sessionId)
|
||||
}
|
||||
|
||||
// Per-session equivalent of sendMessage — writes into sessionId's own store
|
||||
// bundle unconditionally (no "is this still on screen" guard needed, since
|
||||
// the bundle IS the screen for this session's window) and shares
|
||||
// `activeControllers` with the singleton path above so cancelStream() from
|
||||
// either a window or the main view (if the same session happens to be open
|
||||
// in both) finds the same in-flight call.
|
||||
export function sendSessionMessage(sessionId: string, text: string) {
|
||||
const chat = chatFor(sessionId)
|
||||
chat.error.set(null)
|
||||
chat.streaming.set(true)
|
||||
|
||||
const userMsg: ChatMessage = { id: mid(), role: 'user', text, tools: [], pendingApprovals: [] }
|
||||
chat.messages.update((ms) => [...ms, userMsg])
|
||||
const assistantMsg: ChatMessage = { id: mid(), role: 'assistant', text: '', tools: [], pendingApprovals: [] }
|
||||
chat.messages.update((ms) => [...ms, assistantMsg])
|
||||
|
||||
let activeTools: Map<string, ToolCallResult> = new Map()
|
||||
let receivedDone = false
|
||||
|
||||
const controller = streamChat(
|
||||
text,
|
||||
sessionId,
|
||||
(ev: ChatEvent) => {
|
||||
if (ev.type === 'session') return // sessionId is already known for a window
|
||||
if (ev.type === 'tool_use') {
|
||||
const tr: ToolCallResult = { type: 'tool_use', name: ev.data.name, id: ev.data.id, args: ev.data.args }
|
||||
activeTools.set(ev.data.id, tr)
|
||||
chat.messages.update((ms) => {
|
||||
const last = ms[ms.length - 1]
|
||||
if (last && last.role === 'assistant') last.tools = [...last.tools, tr]
|
||||
return [...ms]
|
||||
})
|
||||
} else if (ev.type === 'tool_result') {
|
||||
const existing = activeTools.get(ev.data.id)
|
||||
if (existing) {
|
||||
const updated: ToolCallResult = { ...existing, type: 'tool_result', result: ev.data.result, error: ev.data.error }
|
||||
activeTools.set(ev.data.id, updated)
|
||||
chat.messages.update((ms) => {
|
||||
const last = ms[ms.length - 1]
|
||||
if (last && last.role === 'assistant') {
|
||||
last.tools = last.tools.map((t) => (t.id === ev.data.id ? updated : t))
|
||||
last.pendingApprovals = extractApprovals(last.tools)
|
||||
}
|
||||
return [...ms]
|
||||
})
|
||||
}
|
||||
} else if (ev.type === 'text_delta') {
|
||||
chat.messages.update((ms) => {
|
||||
const last = ms[ms.length - 1]
|
||||
if (last && last.role === 'assistant') last.text += ev.data
|
||||
return [...ms]
|
||||
})
|
||||
} else if (ev.type === 'text') {
|
||||
chat.messages.update((ms) => {
|
||||
const last = ms[ms.length - 1]
|
||||
if (last && last.role === 'assistant') last.text = ev.data
|
||||
return [...ms]
|
||||
})
|
||||
} else if (ev.type === 'done') {
|
||||
receivedDone = true
|
||||
chat.connectionState.set('connected')
|
||||
chat.messages.update((ms) => {
|
||||
const last = ms[ms.length - 1]
|
||||
if (last && last.role === 'assistant') last.pendingApprovals = extractApprovals(last.tools)
|
||||
return [...ms]
|
||||
})
|
||||
startSessionPolling(sessionId)
|
||||
} else if (ev.type === 'error') {
|
||||
chat.error.set(ev.data)
|
||||
}
|
||||
},
|
||||
(err: string) => {
|
||||
if (err === 'AbortError' || err.includes('aborted')) {
|
||||
chat.streaming.set(false)
|
||||
return
|
||||
}
|
||||
chat.error.set(err)
|
||||
if (!receivedDone) {
|
||||
chat.connectionState.set('disconnected')
|
||||
startSessionPolling(sessionId)
|
||||
addChatError('Agent connection lost. The task is still running — retrying…', 'Dismiss')
|
||||
} else {
|
||||
chat.streaming.set(false)
|
||||
}
|
||||
},
|
||||
() => {
|
||||
chat.streaming.set(false)
|
||||
if (activeControllers.get(sessionId) === controller) activeControllers.delete(sessionId)
|
||||
loadSessions()
|
||||
}
|
||||
)
|
||||
activeControllers.set(sessionId, controller)
|
||||
}
|
||||
|
||||
export function cancelSessionStream(sessionId: string) {
|
||||
const controller = activeControllers.get(sessionId)
|
||||
if (!controller) return
|
||||
controller.abort()
|
||||
activeControllers.delete(sessionId)
|
||||
chatFor(sessionId).streaming.set(false)
|
||||
}
|
||||
|
||||
@@ -22,3 +22,20 @@ export function openEntityWindow(slug: string | null): void {
|
||||
}
|
||||
wm.open({ id: slug, title: slug })
|
||||
}
|
||||
|
||||
// Same dedupe/restore/focus pattern as openEntityWindow, for a task/session's
|
||||
// chat window. Id is namespaced `session:<id>` — distinct from entity window
|
||||
// ids (always a bare `type:identifier` slug, and task ENTITIES already use
|
||||
// `task:<uuid>` as their own slug) so a task's chat window and its entity
|
||||
// detail window never collide over the same wmkit id. See
|
||||
// EntityDesktop.svelte for the id -> content-component branch.
|
||||
export function openTaskWindow(sessionId: string | null, title: string): void {
|
||||
if (!sessionId) return
|
||||
const id = `session:${sessionId}`
|
||||
if (wm.get(id)) {
|
||||
wm.restore(id)
|
||||
wm.focus(id)
|
||||
return
|
||||
}
|
||||
wm.open({ id, title, width: 900, height: 640 })
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { writable, derived, get } from 'svelte/store'
|
||||
import { writable, derived, get, type Writable, type Readable } from 'svelte/store'
|
||||
import { liveEvents, subscribeEvents } from './events'
|
||||
import { 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<PlanStep[]>([])
|
||||
export const questions = writable<SessionQuestion[]>([])
|
||||
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<TouchedEntity[]>([])
|
||||
const TOUCHED_MAX = 12
|
||||
const TOUCHED_PULSE_MS = 6000
|
||||
|
||||
@@ -40,9 +35,35 @@ export interface HealthDiff {
|
||||
to: string
|
||||
ts: number
|
||||
}
|
||||
export const healthDiffs = writable<HealthDiff[]>([])
|
||||
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.
|
||||
// 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<typeof setTimeout> | 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<string, unknown>
|
||||
|
||||
// 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<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(() => {
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user