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,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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user