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

@@ -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)
}