From ea229ea7f3aec46bb52bbeeffb6c697683c7efc0 Mon Sep 17 00:00:00 2001 From: Mason <31372737+Ovaculos@users.noreply.github.com> Date: Mon, 25 May 2026 10:25:10 -0600 Subject: [PATCH 01/26] feat(runtime): server-authoritative resumable chat turns (RunBus) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Decouple a chat turn's lifecycle from the originating HTTP request. A turn now runs to completion on the server regardless of the client connection; the client is a viewer that attaches to a replayable per-conversation event log (RunBus), replays the in-flight turn, then tails live events. Refresh / conversation-switch / disconnect never lose or duplicate work — they detach and re-attach. Only the Stop button (RunBus.cancel) aborts generation. - RunBus: in-memory, replayable, monotonic-seq turn log with grace-window retention for late re-attach; threads its own AbortSignal into the engine. - runtime.startTurn: detached turn driver; seeds user.message into the log; reports isTurnActive / activeConversationIds / turnSeq / getTurnReplay. - Per-conversation SSE: ConversationEventManager + /v1/conversations/:id/events route with subscribed{isActive} frame + buffered replay; /v1/chat/start and cancel endpoints. - Broadcast conversation.title + data.changed on the global SSE when a title generates, so viewers update without a turn-stream connection. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/api/conversation-events.ts | 53 ++++- src/api/events.ts | 5 + src/api/handlers.ts | 77 ++++++ src/api/routes/chat.ts | 62 +++-- src/api/routes/conversation-events.ts | 29 ++- src/api/server.ts | 7 + src/conversation/event-sourced-store.ts | 2 +- src/conversation/types.ts | 4 + src/engine/types.ts | 1 + src/runtime/run-bus.ts | 247 ++++++++++++++++++++ src/runtime/runtime.ts | 203 +++++++++++++++- test/integration/detached-turn-http.test.ts | 120 ++++++++++ test/integration/detached-turn.test.ts | 99 ++++++++ test/unit/run-bus.test.ts | 154 ++++++++++++ 14 files changed, 1025 insertions(+), 38 deletions(-) create mode 100644 src/runtime/run-bus.ts create mode 100644 test/integration/detached-turn-http.test.ts create mode 100644 test/integration/detached-turn.test.ts create mode 100644 test/unit/run-bus.test.ts diff --git a/src/api/conversation-events.ts b/src/api/conversation-events.ts index 368110e6..e36a3c3e 100644 --- a/src/api/conversation-events.ts +++ b/src/api/conversation-events.ts @@ -8,6 +8,8 @@ * Separate from SseEventManager which handles workspace-level events. */ +import type { BufferedRunEvent } from "../runtime/run-bus.ts"; + /** A subscriber watching a specific conversation's events. */ interface ConversationSubscriber { id: string; @@ -19,6 +21,13 @@ interface ConversationSubscriber { const encoder = new TextEncoder(); +/** Format an SSE frame. `seq`, when present, is sent as the `id:` line so a + * reconnecting viewer can resume from its last-seen sequence number. */ +function frame(eventType: string, data: unknown, seq?: number): Uint8Array { + const idLine = seq != null ? `id: ${seq}\n` : ""; + return encoder.encode(`event: ${eventType}\n${idLine}data: ${JSON.stringify(data)}\n\n`); +} + export class ConversationEventManager { private subscribers = new Map(); private heartbeatTimer: ReturnType | null = null; @@ -73,6 +82,8 @@ export class ConversationEventManager { addSubscriber( conversationId: string, userId: string, + replay?: BufferedRunEvent[], + meta?: { isActive: boolean; activeSeq: number }, ): { stream: ReadableStream; subscriberId: string } { const id = crypto.randomUUID(); let sub: ConversationSubscriber; @@ -80,9 +91,24 @@ export class ConversationEventManager { const stream = new ReadableStream({ start: (controller) => { sub = { id, userId, conversationId, controller, closed: false }; + // The subscribed frame tells the client whether a turn is in flight + // (so it can trim a stale in-flight turn from disk history before the + // RunBus replay rebuilds it) and its current seq. + controller.enqueue( + frame("subscribed", { + subscriberId: id, + isActive: meta?.isActive ?? false, + activeSeq: meta?.activeSeq ?? 0, + }), + ); + // Replay the in-flight turn (if any) BEFORE registering for live + // fan-out. start() runs synchronously and we add to the subscribers + // map only after replaying, so no live event can interleave ahead of + // the replay — viewers never see out-of-order deltas. + if (replay) { + for (const e of replay) controller.enqueue(frame(e.type, e.data, e.seq)); + } this.subscribers.set(id, sub); - const subscribedMsg = `event: subscribed\ndata: ${JSON.stringify({ subscriberId: id })}\n\n`; - controller.enqueue(encoder.encode(subscribedMsg)); }, cancel: () => { this.removeSubscriber(id); @@ -92,6 +118,29 @@ export class ConversationEventManager { return { stream, subscriberId: id }; } + /** + * Fan out a live run event (with its sequence number) to every subscriber + * of the conversation. The seq lets viewers de-duplicate against replay and + * resume after a reconnect. + */ + publishEvent(conversationId: string, event: BufferedRunEvent): void { + const encoded = frame(event.type, event.data, event.seq); + for (const [id, sub] of this.subscribers) { + if (sub.closed) { + this.subscribers.delete(id); + continue; + } + if (sub.conversationId !== conversationId) continue; + try { + sub.controller.enqueue(encoded); + } catch (err) { + console.warn("[conversation-events] SSE write failed:", err); + this.closeSub(sub); + this.subscribers.delete(id); + } + } + } + /** Remove a specific subscriber. */ removeSubscriber(subscriberId: string): void { const sub = this.subscribers.get(subscriberId); diff --git a/src/api/events.ts b/src/api/events.ts index 235da077..e2f96572 100644 --- a/src/api/events.ts +++ b/src/api/events.ts @@ -59,6 +59,11 @@ const SSE_ROUTES: Partial> = { // existing "broadcast to all clients in this process" behavior to avoid // silently breaking iframe refresh. Revisit when payload grows wsId. "data.changed": { scope: "global" }, + // Live conversation-title update (auto-title generation completes after the + // turn). Workspace-scoped via the conversation's workspaceId breadcrumb so + // it doesn't leak across tenants. The shell routes it to the matching + // conversation slice by `conversationId`. + "conversation.title": { scope: "workspace", wsIdField: "wsId" }, // Org-level config (model preferences, feature flags). Affects every // workspace; broadcast to all. "config.changed": { scope: "global" }, diff --git a/src/api/handlers.ts b/src/api/handlers.ts index 1988d0fa..26dfede0 100644 --- a/src/api/handlers.ts +++ b/src/api/handlers.ts @@ -8,6 +8,7 @@ import { ingestFiles, isAllowedMime, type UploadedFile } from "../files/ingest.t import { createFileStore } from "../files/store.ts"; import type { FileEntry } from "../files/types.ts"; import type { IdentityProvider, UserIdentity } from "../identity/provider.ts"; +import { DEV_IDENTITY } from "../identity/providers/dev.ts"; import { ConversationAccessDeniedError, ConversationCorruptedError, @@ -141,6 +142,82 @@ function runInProgressResponse(conversationId: string): Response { ); } +/** + * Handle POST /v1/chat/start — kick off a detached, server-authoritative turn + * and return the conversation id immediately. The turn runs to completion on + * the server regardless of this request's lifecycle (closing the tab does NOT + * cancel it). Clients watch the turn via GET /v1/conversations/:id/events, + * which replays the in-flight turn then tails live. + */ +export async function handleChatStart( + request: Request, + runtime: Runtime, + features: ResolvedFeatures, + identity?: UserIdentity, + workspaceId?: string, +): Promise { + const parsed = await parseChatBody(request, runtime, features, identity, workspaceId); + if (parsed instanceof Response) return parsed; + try { + const { conversationId } = await runtime.startTurn(parsed); + return Response.json({ conversationId }); + } catch (err) { + if (err instanceof RunInProgressError) { + return runInProgressResponse(parsed.conversationId ?? ""); + } + if (err instanceof ConversationAccessDeniedError) { + return apiError( + 403, + "conversation_access_denied", + "You do not have access to this conversation.", + { conversationId: parsed.conversationId }, + ); + } + throw err; + } +} + +/** + * Handle POST /v1/conversations/:id/cancel — the explicit Stop button. The + * ONLY thing that aborts generation; client disconnect does not. Ownership is + * enforced (same posture as the events route). + */ +export async function handleChatCancel( + conversationId: string, + runtime: Runtime, + identity?: UserIdentity, +): Promise { + const callerId = identity?.id ?? (runtime.getIdentityProvider() ? null : DEV_IDENTITY.id); + if (!callerId) { + return apiError(401, "authentication_required", "Authentication required."); + } + const conversation = await runtime.findConversation(conversationId).catch((err) => { + if (err instanceof ConversationCorruptedError) return err; + throw err; + }); + if (conversation instanceof ConversationCorruptedError) { + return apiError(422, "conversation_corrupted", conversation.message, { + conversationId: conversation.conversationId, + reason: conversation.reason, + }); + } + if (!conversation) { + return apiError(404, "not_found", "Conversation not found"); + } + if (conversation.ownerId !== callerId) { + return apiError( + 403, + "conversation_access_denied", + "You do not have access to this conversation.", + { + conversationId, + }, + ); + } + const cancelled = runtime.cancelTurn(conversationId); + return Response.json({ cancelled }); +} + function conversationAccessDeniedResponse(conversationId: string): Response { return apiError( 403, diff --git a/src/api/routes/chat.ts b/src/api/routes/chat.ts index 5ec79d3a..f14405fb 100644 --- a/src/api/routes/chat.ts +++ b/src/api/routes/chat.ts @@ -1,5 +1,5 @@ import { Hono } from "hono"; -import { handleChat, handleChatStream } from "../handlers.ts"; +import { handleChat, handleChatCancel, handleChatStart, handleChatStream } from "../handlers.ts"; import { requireAuth } from "../middleware/auth.ts"; import { bodyLimit } from "../middleware/body-limit.ts"; import { errorLog } from "../middleware/error-log.ts"; @@ -15,28 +15,40 @@ export function chatRoutes(ctx: AppContext) { const chatBodyLimit = bodyLimit(1_048_576, { multipart: ctx.runtime.getFilesConfig().maxTotalSize, }); - return new Hono() - .use("*", requireAuth(ctx.authOptions)) - .use("*", requireWorkspace(ctx.workspaceStore)) - .use("*", errorLog(ctx)) - .post("/v1/chat", chatBodyLimit, rl, (c) => - handleChat( - c.req.raw, - ctx.runtime, - ctx.features, - c.var.identity, - c.var.workspaceId, - ctx.conversationEventManager, - ), - ) - .post("/v1/chat/stream", chatBodyLimit, rl, (c) => - handleChatStream( - c.req.raw, - ctx.runtime, - ctx.features, - c.var.identity, - c.var.workspaceId, - ctx.conversationEventManager, - ), - ); + return ( + new Hono() + .use("*", requireAuth(ctx.authOptions)) + .use("*", requireWorkspace(ctx.workspaceStore)) + .use("*", errorLog(ctx)) + .post("/v1/chat", chatBodyLimit, rl, (c) => + handleChat( + c.req.raw, + ctx.runtime, + ctx.features, + c.var.identity, + c.var.workspaceId, + ctx.conversationEventManager, + ), + ) + .post("/v1/chat/stream", chatBodyLimit, rl, (c) => + handleChatStream( + c.req.raw, + ctx.runtime, + ctx.features, + c.var.identity, + c.var.workspaceId, + ctx.conversationEventManager, + ), + ) + // Server-authoritative entry point: starts a detached turn and returns + // the conversation id immediately. The client then watches via + // GET /v1/conversations/:id/events. Generation survives client disconnect. + .post("/v1/chat/start", chatBodyLimit, rl, (c) => + handleChatStart(c.req.raw, ctx.runtime, ctx.features, c.var.identity, c.var.workspaceId), + ) + // Explicit Stop — the only way to abort an in-flight turn. + .post("/v1/conversations/:id/cancel", (c) => + handleChatCancel(c.req.param("id"), ctx.runtime, c.var.identity), + ) + ); } diff --git a/src/api/routes/conversation-events.ts b/src/api/routes/conversation-events.ts index 0b150026..d0acdf4f 100644 --- a/src/api/routes/conversation-events.ts +++ b/src/api/routes/conversation-events.ts @@ -92,13 +92,28 @@ export function conversationEventRoutes(ctx: AppContext) { ); } - // Create SSE stream for this subscriber. The first frame - // (event: subscribed) carries the server-generated subscriberId - // so the client can pass it back as `X-Origin-Subscriber-Id` on - // any chat-stream POST it originates — that prevents the - // chat-stream's broadcast from echoing back to this same - // subscription. - const { stream } = ctx.conversationEventManager.addSubscriber(conversationId, callerId); + // Resume point: the client passes the highest sequence number it has + // already rendered (0 / absent = full replay of the in-flight turn). + const afterSeqRaw = c.req.query("afterSeq"); + const afterSeq = afterSeqRaw ? Number.parseInt(afterSeqRaw, 10) : 0; + const replay = ctx.runtime.getTurnReplay( + conversationId, + Number.isFinite(afterSeq) ? afterSeq : 0, + ); + + // Create the SSE stream. The manager replays the buffered in-flight turn + // (events with seq > afterSeq) before registering for live fan-out, so a + // page refresh reconstructs the in-progress assistant message and then + // tails the rest with no gap or duplication. + const { stream } = ctx.conversationEventManager.addSubscriber( + conversationId, + callerId, + replay, + { + isActive: ctx.runtime.isTurnActive(conversationId), + activeSeq: ctx.runtime.turnSeq(conversationId), + }, + ); return new Response(stream, { headers: { diff --git a/src/api/server.ts b/src/api/server.ts index 3c1c3122..5b4288d9 100644 --- a/src/api/server.ts +++ b/src/api/server.ts @@ -73,6 +73,13 @@ export function startServer(options: ServerOptions): ServerHandle { const conversationEventManager = new ConversationEventManager(); conversationEventManager.start(); + // Bridge detached-turn events (RunBus) to the per-conversation SSE manager + // so connected viewers tail live. Replay-on-connect is sourced separately + // from the RunBus buffer (see the conversation-events route). + runtime.onTurnEvent = (conversationId, event) => { + conversationEventManager.publishEvent(conversationId, event); + }; + // Login rate limiter — per-IP brute-force protection const rateLimiter = new LoginRateLimiter(); rateLimiter.start(); diff --git a/src/conversation/event-sourced-store.ts b/src/conversation/event-sourced-store.ts index 7b3e5a45..e386f6c0 100644 --- a/src/conversation/event-sourced-store.ts +++ b/src/conversation/event-sourced-store.ts @@ -129,7 +129,7 @@ export class EventSourcedConversationStore implements ConversationStore, EventSi // ========================================================================= async create(options: CreateConversationOptions): Promise { - const id = `conv_${crypto.randomUUID().replace(/-/g, "").slice(0, 16)}`; + const id = options.id ?? `conv_${crypto.randomUUID().replace(/-/g, "").slice(0, 16)}`; const now = new Date().toISOString(); const conversation: Conversation = { id, diff --git a/src/conversation/types.ts b/src/conversation/types.ts index 69016424..133d536b 100644 --- a/src/conversation/types.ts +++ b/src/conversation/types.ts @@ -75,6 +75,10 @@ export interface CreateConversationOptions { workspaceId?: string; ownerId: string; metadata?: Record; + /** Create with a specific id instead of a generated one. Used by the + * detached-turn path so the conversation id is known to the caller before + * the engine run starts. */ + id?: string; } /** Fields that can be updated on a conversation. */ diff --git a/src/engine/types.ts b/src/engine/types.ts index a320be3e..b20ade40 100644 --- a/src/engine/types.ts +++ b/src/engine/types.ts @@ -95,6 +95,7 @@ export type EngineEventType = */ | "connection.state_changed" | "data.changed" + | "conversation.title" | "config.changed" | "skill.created" | "skill.updated" diff --git a/src/runtime/run-bus.ts b/src/runtime/run-bus.ts new file mode 100644 index 00000000..f69cc31e --- /dev/null +++ b/src/runtime/run-bus.ts @@ -0,0 +1,247 @@ +/** + * RunBus — server-authoritative, replayable per-conversation turn log. + * + * A chat turn runs to completion on the server regardless of any client + * connection. The RunBus is the in-memory source of truth for an in-flight + * turn: it owns the turn's cancellation handle, an ordered event log with + * monotonic sequence numbers, and the set of live subscribers. + * + * Why it exists (issue #254 follow-up — conversation-tab rewrite): + * - The client is a *viewer*. It attaches to a run, replays everything + * emitted so far (so a page refresh reconstructs the in-progress + * assistant message), then tails live events. Disconnect / refresh / + * conversation-switch never lose or duplicate work — they just detach + * and re-attach. + * - The turn's lifecycle is decoupled from the originating HTTP request. + * Closing the tab does NOT abort generation; only an explicit + * {@link RunBus.cancel} (the Stop button) does. + * + * Scope: single-process, in-memory. Multi-replica (`platform.replicas > 1`) + * needs a Redis-backed log + conversationId-sticky routing — deferred, + * mirrors the `SessionRegistry` pattern. + */ + +import { RunInProgressError } from "./errors.ts"; + +export type RunStatus = "running" | "done" | "error" | "cancelled"; + +/** A single buffered event in a run's log. `seq` is 1-based and monotonic. */ +export interface BufferedRunEvent { + seq: number; + type: string; + data: unknown; +} + +interface RunLog { + conversationId: string; + seq: number; + events: BufferedRunEvent[]; + status: RunStatus; + startedAt: number; + endedAt?: number; + abort: AbortController; + eventListeners: Set<(e: BufferedRunEvent) => void>; + endListeners: Set<(s: RunStatus) => void>; + gcTimer?: ReturnType; +} + +/** Detach callback returned by {@link RunBus.attach}. */ +export type DetachFn = () => void; + +export class RunBus { + private runs = new Map(); + /** How long a terminal run's log is retained for late re-attach. */ + private readonly graceMs: number; + + constructor(graceMs = 30_000) { + this.graceMs = graceMs; + } + + /** + * Begin a turn for a conversation. Throws {@link RunInProgressError} if one + * is already running. Returns the turn's `AbortSignal` — the engine threads + * this (NOT the HTTP request's signal), so generation survives client + * disconnect and is only stopped by {@link cancel}. + */ + begin(conversationId: string): AbortSignal { + const existing = this.runs.get(conversationId); + if (existing && existing.status === "running") { + throw new RunInProgressError(conversationId); + } + // A terminal log lingering in its grace window is replaced by the new turn. + if (existing?.gcTimer) clearTimeout(existing.gcTimer); + + const log: RunLog = { + conversationId, + seq: 0, + events: [], + status: "running", + startedAt: Date.now(), + abort: new AbortController(), + eventListeners: new Set(), + endListeners: new Set(), + }; + this.runs.set(conversationId, log); + return log.abort.signal; + } + + /** Whether a turn is currently generating for this conversation. */ + isActive(conversationId: string): boolean { + return this.runs.get(conversationId)?.status === "running"; + } + + /** Last sequence number assigned for this conversation's current/last run. */ + currentSeq(conversationId: string): number { + return this.runs.get(conversationId)?.seq ?? 0; + } + + /** Status of the conversation's current/last (still-retained) run. */ + getStatus(conversationId: string): RunStatus | undefined { + return this.runs.get(conversationId)?.status; + } + + /** Conversation ids with an actively generating turn. */ + activeConversationIds(): string[] { + const ids: string[] = []; + for (const [id, log] of this.runs) { + if (log.status === "running") ids.push(id); + } + return ids; + } + + /** + * Append an event to the run's log and fan it out to live subscribers. + * No-op if the run isn't active (defensive — late engine events after a + * cancel shouldn't resurrect a terminated log). + */ + publish(conversationId: string, type: string, data: unknown): BufferedRunEvent | null { + const log = this.runs.get(conversationId); + if (!log || log.status !== "running") return null; + log.seq += 1; + const evt: BufferedRunEvent = { seq: log.seq, type, data }; + log.events.push(evt); + for (const fn of log.eventListeners) { + try { + fn(evt); + } catch { + // A failing subscriber must not break the fan-out to others. + } + } + return evt; + } + + /** + * Snapshot of buffered events with `seq > afterSeq` (no live subscription). + * Used to replay an in-progress turn to a freshly connecting SSE subscriber + * before it starts receiving live fan-out. Empty if no retained run. + */ + bufferedSince(conversationId: string, afterSeq: number): BufferedRunEvent[] { + const log = this.runs.get(conversationId); + if (!log) return []; + return log.events.filter((e) => e.seq > afterSeq); + } + + /** Mark a run terminal, notify end-listeners, and schedule log GC. */ + end(conversationId: string, status: Exclude): void { + const log = this.runs.get(conversationId); + if (!log || log.status !== "running") return; + log.status = status; + log.endedAt = Date.now(); + for (const fn of log.endListeners) { + try { + fn(status); + } catch { + // ignore + } + } + this.scheduleGc(log); + } + + /** + * Explicitly cancel an active run (the Stop button). Aborts the turn's + * signal (engine stops cooperatively) and marks it `cancelled`. + */ + cancel(conversationId: string): boolean { + const log = this.runs.get(conversationId); + if (!log || log.status !== "running") return false; + log.abort.abort(); + this.end(conversationId, "cancelled"); + return true; + } + + /** + * Attach a viewer. Synchronously replays every buffered event with + * `seq > afterSeq`, then streams live events as they're published. If the + * run is already terminal, replays the tail then fires `onEnd`. + * + * Pass `afterSeq = 0` for a fresh attach (full replay), or the highest seq + * the client already rendered (from a prior connection) to resume without + * gaps or duplicates. + * + * Returns a detach function. No-op attach (returns a noop) when there's no + * retained run for the conversation — the caller then renders only + * persisted history. + */ + attach( + conversationId: string, + afterSeq: number, + onEvent: (e: BufferedRunEvent) => void, + onEnd?: (s: RunStatus) => void, + ): DetachFn { + const log = this.runs.get(conversationId); + if (!log) return () => {}; + + // Snapshot the replay set before registering the live listener. JS is + // single-threaded and publish() is synchronous, so nothing can interleave + // between the filter and the add — no gaps, no double-delivery. + const replay = log.events.filter((e) => e.seq > afterSeq); + const liveListener = (e: BufferedRunEvent) => onEvent(e); + log.eventListeners.add(liveListener); + + let endListener: ((s: RunStatus) => void) | undefined; + if (onEnd) { + endListener = (s) => onEnd(s); + log.endListeners.add(endListener); + } + + for (const e of replay) onEvent(e); + // Already-terminal run: deliver the terminal status after the replay. + if (log.status !== "running" && onEnd) onEnd(log.status); + + return () => { + log.eventListeners.delete(liveListener); + if (endListener) log.endListeners.delete(endListener); + }; + } + + /** Drop a retained terminal log immediately (test/GC helper). */ + evict(conversationId: string): void { + const log = this.runs.get(conversationId); + if (log?.gcTimer) clearTimeout(log.gcTimer); + this.runs.delete(conversationId); + } + + /** Cancel all active runs and clear state (shutdown / reset). */ + reset(): void { + for (const log of this.runs.values()) { + if (log.gcTimer) clearTimeout(log.gcTimer); + if (log.status === "running") log.abort.abort(); + } + this.runs.clear(); + } + + private scheduleGc(log: RunLog): void { + if (this.graceMs <= 0) { + this.runs.delete(log.conversationId); + return; + } + log.gcTimer = setTimeout(() => { + // Only GC if a newer run hasn't replaced this one. + if (this.runs.get(log.conversationId) === log) { + this.runs.delete(log.conversationId); + } + }, this.graceMs); + // Don't keep the process alive solely for log GC. + log.gcTimer.unref?.(); + } +} diff --git a/src/runtime/runtime.ts b/src/runtime/runtime.ts index d6497409..96ad3c41 100644 --- a/src/runtime/runtime.ts +++ b/src/runtime/runtime.ts @@ -95,6 +95,7 @@ import { type RequestContext, runWithRequestContext, } from "./request-context.ts"; +import { type BufferedRunEvent, RunBus, type RunStatus } from "./run-bus.ts"; import { buildSkillsLoadedPayload } from "./skills-loaded-payload.ts"; import { surfaceTools } from "./tools.ts"; import type { ChatRequest, ChatResult, ModelSlots, RuntimeConfig, TurnUsage } from "./types.ts"; @@ -254,6 +255,14 @@ export class Runtime { */ private readonly activeConversations = new Set(); + /** + * Server-authoritative, replayable log of in-flight turns. Detached web + * chats run through {@link startTurn}; their engine events are published + * here so any viewer (live, reconnecting, or cross-tab) can replay + tail. + * The turn's cancellation lives here too — client disconnect does NOT abort. + */ + private readonly runBus = new RunBus(); + private constructor( _engine: AgentEngine, resolveModelFn: (modelString: string) => LanguageModelV3, @@ -726,6 +735,183 @@ export class Runtime { } } + // =========================================================================== + // Detached, server-authoritative turns (conversation-tab rewrite). + // + // `startTurn` runs a chat turn to completion regardless of the caller's + // connection. The conversation id is resolved up front and returned + // immediately; the engine run continues in the background, publishing every + // event to the RunBus. Clients are viewers — they `attachTurn` to replay + + // tail. Client disconnect does NOT abort; only `cancelTurn` (Stop) does. + // =========================================================================== + + /** Whether a detached turn is currently generating for this conversation. */ + isTurnActive(conversationId: string): boolean { + return this.runBus.isActive(conversationId); + } + + /** Conversation ids with an actively generating turn (drives the list dot). */ + activeTurnConversationIds(): string[] { + return this.runBus.activeConversationIds(); + } + + /** Highest event sequence number for a conversation's current/last run. */ + turnSeq(conversationId: string): number { + return this.runBus.currentSeq(conversationId); + } + + /** + * Attach a viewer to a conversation's in-flight turn: replays buffered + * events with `seq > afterSeq`, then tails live ones. Returns a detach fn + * (a safe no-op when nothing is running). Detaching never cancels the turn. + */ + attachTurn( + conversationId: string, + afterSeq: number, + onEvent: (e: BufferedRunEvent) => void, + onEnd?: (s: RunStatus) => void, + ): () => void { + return this.runBus.attach(conversationId, afterSeq, onEvent, onEnd); + } + + /** Explicitly cancel an in-flight turn (the Stop button). */ + cancelTurn(conversationId: string): boolean { + return this.runBus.cancel(conversationId); + } + + /** + * Start a chat turn that runs to completion server-side, decoupled from the + * caller's connection. Resolves (creating if new) the conversation id up + * front, reserves the run on the RunBus, then runs the engine in the + * background — publishing every event to the bus so viewers can replay + + * tail via {@link attachTurn}. Returns once the id is known; the turn keeps + * running after the HTTP request that called this returns. Throws + * {@link RunInProgressError} if a turn is already active for the conversation. + */ + async startTurn(request: ChatRequest): Promise<{ conversationId: string }> { + if (!request.workspaceId) { + throw new Error("workspaceId is required. Every chat request must be workspace-scoped."); + } + const store = this.findConversationStore(); + const ownerId = this.resolveOwnerId(request); + const createOpts: CreateConversationOptions = { + ownerId, + workspaceId: request.workspaceId, + ...(request.metadata ? { metadata: request.metadata } : {}), + }; + + const isNew = !request.conversationId; + let conversationId: string; + if (request.conversationId) { + const existing = await store.load(request.conversationId); + if (existing && existing.ownerId !== ownerId) { + throw new ConversationAccessDeniedError(request.conversationId, ownerId); + } + conversationId = + existing?.id ?? (await store.create({ ...createOpts, id: request.conversationId })).id; + } else { + conversationId = (await store.create(createOpts)).id; + } + + // Reserve the run (throws if already active). The returned signal is the + // RunBus's — NOT the HTTP request's — so client disconnect won't abort. + const signal = this.runBus.begin(conversationId); + + // Seed the run stream with the user's message so the turn is + // self-contained: any viewer (sender, other tab, post-refresh) can + // reconstruct user + assistant from replay alone, no optimistic client + // state required. + this.publishTurnEvent(conversationId, "user.message", { + content: request.message, + ...(ownerId ? { userId: ownerId } : {}), + timestamp: new Date().toISOString(), + }); + + // Tell conversation-list UIs a new conversation exists (so the row + its + // streaming dot appear immediately). Resolved-existing turns already have + // a row. + if (isNew) this.emitConversationsChanged(); + + const busSink = this.createRunBusSink(conversationId); + // Detached: run to completion regardless of the caller's connection. + void this.chat({ ...request, conversationId, signal }, busSink) + .then((result) => { + // Publish a terminal `done` carrying the final result so viewers + // finalize the assistant message, then close the run. + this.publishTurnEvent(conversationId, "done", { + response: result.response, + conversationId: result.conversationId, + toolCalls: result.toolCalls, + stopReason: result.stopReason, + usage: result.usage, + }); + this.runBus.end(conversationId, "done"); + }) + .catch((err) => { + if (signal.aborted) { + this.publishTurnEvent(conversationId, "cancelled", {}); + this.runBus.end(conversationId, "cancelled"); + } else { + this.publishTurnEvent(conversationId, "error", { + error: "engine_error", + message: err instanceof Error ? err.message : String(err), + }); + this.runBus.end(conversationId, "error"); + } + }) + .finally(() => { + // Refresh list UIs so the row's dot clears and the final title shows. + this.emitConversationsChanged(); + }); + + return { conversationId }; + } + + /** + * Live fan-out hook for detached-turn events. The API layer sets this to + * forward each published event to the per-conversation SSE manager so + * connected viewers tail in real time. Buffering/replay stays in the RunBus; + * this is purely the live edge. + */ + onTurnEvent?: (conversationId: string, event: BufferedRunEvent) => void; + + /** Replay snapshot of an in-flight turn for a newly connecting viewer. */ + getTurnReplay(conversationId: string, afterSeq: number): BufferedRunEvent[] { + return this.runBus.bufferedSince(conversationId, afterSeq); + } + + /** Publish to the RunBus (buffer/replay) and fan out live (SSE viewers). */ + private publishTurnEvent(conversationId: string, type: string, data: unknown): void { + const buffered = this.runBus.publish(conversationId, type, data); + if (buffered) this.onTurnEvent?.(conversationId, buffered); + } + + /** EventSink that forwards engine events into the RunBus for one turn. */ + private createRunBusSink(conversationId: string): EventSink { + return { + emit: (event: EngineEvent) => { + this.publishTurnEvent(conversationId, event.type, event.data); + }, + }; + } + + /** Broadcast a conversations-list change on the global sink (→ SSE → iframe). */ + private emitConversationsChanged(): void { + this.defaultEvents.emit({ + type: "data.changed", + data: { server: "conversations", tool: "list" }, + }); + } + + private resolveOwnerId(request: ChatRequest): string { + if (request.identity?.id) return request.identity.id; + if (!this._identityProvider) return "usr_default"; + throw new Error( + "[runtime.startTurn] no identity on request — the auth middleware must populate " + + "request.identity before the turn starts.", + ); + } + private async _chatInner(request: ChatRequest, requestSink?: EventSink): Promise { if (!request.workspaceId) { throw new Error("workspaceId is required. Every chat request must be workspace-scoped."); @@ -1211,15 +1397,26 @@ export class Runtime { }); } - // Fire-and-forget title generation on first turn (use "fast" slot for cost savings) + // Fire-and-forget title generation on first turn (use "fast" slot for cost + // savings). Decoupled from the turn lifecycle: when it resolves we persist + // the title, then broadcast `conversation.title` on the global SSE so any + // live viewer's panel header updates in place, and refresh the + // conversations list. The global channel (not the turn stream, which the + // client closes on `done`) means delivery is reliable after the turn ends + // and across tabs — routed to the right conversation by `conversationId`. if (conversation.title === null) { const titleModel = this.resolveModelFn(this.getModelSlot("fast")); const titleInput = request.message || `[Uploaded: ${request.fileRefs?.map((f) => f.filename).join(", ") || "files"}]`; void generateTitle(titleModel, titleInput, result.output).then( - (title) => { - void store.update(conversation.id, { title }); + async (title) => { + await store.update(conversation.id, { title }); + this.defaultEvents.emit({ + type: "conversation.title", + data: { conversationId: conversation.id, title, wsId }, + }); + this.emitConversationsChanged(); }, (err) => console.error("[runtime] title generation failed:", err), ); diff --git a/test/integration/detached-turn-http.test.ts b/test/integration/detached-turn-http.test.ts new file mode 100644 index 00000000..c79d52ee --- /dev/null +++ b/test/integration/detached-turn-http.test.ts @@ -0,0 +1,120 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { mkdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { type ServerHandle, startServer } from "../../src/api/server.ts"; +import { Runtime } from "../../src/runtime/runtime.ts"; +import { createEchoModel } from "../helpers/echo-model.ts"; +import { TEST_WORKSPACE_ID, provisionTestWorkspace } from "../helpers/test-workspace.ts"; + +let runtime: Runtime; +let handle: ServerHandle; +let baseUrl: string; +const testDir = join(tmpdir(), `nimblebrain-detached-http-${Date.now()}`); + +beforeAll(async () => { + mkdirSync(testDir, { recursive: true }); + runtime = await Runtime.start({ + model: { provider: "custom", adapter: createEchoModel() }, + noDefaultBundles: true, + logging: { disabled: true }, + workDir: testDir, + }); + await provisionTestWorkspace(runtime); + handle = startServer({ runtime, port: 0 }); + baseUrl = `http://localhost:${handle.port}`; +}); + +afterAll(async () => { + handle.stop(true); + await runtime.shutdown(); + rmSync(testDir, { recursive: true, force: true }); +}); + +/** Read an SSE response body for a bounded window, returning event types seen. */ +async function readSse(res: Response, ms: number): Promise { + const reader = res.body!.getReader(); + const decoder = new TextDecoder(); + const types: string[] = []; + const deadline = Date.now() + ms; + let buffer = ""; + try { + while (Date.now() < deadline) { + const chunk = await Promise.race([ + reader.read(), + new Promise<{ done: true; value: undefined }>((r) => + setTimeout(() => r({ done: true, value: undefined }), deadline - Date.now()), + ), + ]); + if (chunk.done || !chunk.value) break; + buffer += decoder.decode(chunk.value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() ?? ""; + for (const line of lines) { + if (line.startsWith("event: ")) types.push(line.slice(7).trim()); + } + } + } finally { + await reader.cancel().catch(() => {}); + } + return types; +} + +describe("detached turn HTTP surface", () => { + it("POST /v1/chat/start returns a conversation id immediately", async () => { + const res = await fetch(`${baseUrl}/v1/chat/start`, { + method: "POST", + headers: { "Content-Type": "application/json", "X-Workspace-Id": TEST_WORKSPACE_ID }, + body: JSON.stringify({ message: "Hello over HTTP", workspaceId: TEST_WORKSPACE_ID }), + }); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.conversationId).toMatch(/^conv_/); + }); + + it("GET /v1/conversations/:id/events replays the turn (incl. the user message)", async () => { + const startRes = await fetch(`${baseUrl}/v1/chat/start`, { + method: "POST", + headers: { "Content-Type": "application/json", "X-Workspace-Id": TEST_WORKSPACE_ID }, + body: JSON.stringify({ message: "Replay me", workspaceId: TEST_WORKSPACE_ID }), + }); + const { conversationId } = await startRes.json(); + + // Let the echo turn run + buffer, then connect a fresh viewer — it should + // replay the whole turn from the RunBus (within the grace window). + await new Promise((r) => setTimeout(r, 100)); + + const evRes = await fetch(`${baseUrl}/v1/conversations/${conversationId}/events`, { + headers: { "X-Workspace-Id": TEST_WORKSPACE_ID }, + }); + expect(evRes.status).toBe(200); + const types = await readSse(evRes, 400); + expect(types).toContain("subscribed"); + expect(types).toContain("user.message"); + expect(types).toContain("chat.start"); + }); + + it("POST /v1/conversations/:id/cancel returns ok for the owner", async () => { + const startRes = await fetch(`${baseUrl}/v1/chat/start`, { + method: "POST", + headers: { "Content-Type": "application/json", "X-Workspace-Id": TEST_WORKSPACE_ID }, + body: JSON.stringify({ message: "cancel target", workspaceId: TEST_WORKSPACE_ID }), + }); + const { conversationId } = await startRes.json(); + const res = await fetch(`${baseUrl}/v1/conversations/${conversationId}/cancel`, { + method: "POST", + headers: { "X-Workspace-Id": TEST_WORKSPACE_ID }, + }); + expect(res.status).toBe(200); + const body = await res.json(); + expect(typeof body.cancelled).toBe("boolean"); + }); + + it("cancel of a non-existent conversation is 404", async () => { + const res = await fetch(`${baseUrl}/v1/conversations/conv_0000000000000000/cancel`, { + method: "POST", + headers: { "X-Workspace-Id": TEST_WORKSPACE_ID }, + }); + expect(res.status).toBe(404); + }); +}); diff --git a/test/integration/detached-turn.test.ts b/test/integration/detached-turn.test.ts new file mode 100644 index 00000000..fe82e500 --- /dev/null +++ b/test/integration/detached-turn.test.ts @@ -0,0 +1,99 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { mkdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { EventSourcedConversationStore } from "../../src/conversation/event-sourced-store.ts"; +import { Runtime } from "../../src/runtime/runtime.ts"; +import type { BufferedRunEvent, RunStatus } from "../../src/runtime/run-bus.ts"; +import { createEchoModel } from "../helpers/echo-model.ts"; +import { TEST_WORKSPACE_ID, provisionTestWorkspace } from "../helpers/test-workspace.ts"; + +let runtime: Runtime; +const testDir = join(tmpdir(), `nimblebrain-detached-${Date.now()}`); + +beforeAll(async () => { + mkdirSync(testDir, { recursive: true }); + runtime = await Runtime.start({ + model: { provider: "custom", adapter: createEchoModel() }, + noDefaultBundles: true, + logging: { disabled: true }, + workDir: testDir, + }); + await provisionTestWorkspace(runtime); +}); + +afterAll(async () => { + await runtime.shutdown(); + rmSync(testDir, { recursive: true, force: true }); +}); + +/** Attach to a turn and resolve with all events once it ends. */ +function awaitTurn(conversationId: string): Promise<{ events: BufferedRunEvent[]; status: RunStatus }> { + return new Promise((resolve) => { + const events: BufferedRunEvent[] = []; + runtime.attachTurn( + conversationId, + 0, + (e) => events.push(e), + (status) => resolve({ events, status }), + ); + }); +} + +async function waitFor(pred: () => boolean, timeoutMs = 2000): Promise { + const start = Date.now(); + while (!pred()) { + if (Date.now() - start > timeoutMs) throw new Error("waitFor timed out"); + await new Promise((r) => setTimeout(r, 5)); + } +} + +describe("detached turns (server-authoritative streaming)", () => { + it("returns a conversation id immediately and runs to completion in the background", async () => { + const { conversationId } = await runtime.startTurn({ + message: "Hello detached", + workspaceId: TEST_WORKSPACE_ID, + }); + expect(conversationId).toMatch(/^conv_/); + + const { events, status } = await awaitTurn(conversationId); + expect(status).toBe("done"); + expect(events.some((e) => e.type === "chat.start")).toBe(true); + expect(events.length).toBeGreaterThan(0); + // Sequence numbers are monotonic 1..n. + expect(events.map((e) => e.seq)).toEqual(events.map((_, i) => i + 1)); + }); + + it("persists the turn server-side with no viewer attached", async () => { + const { conversationId } = await runtime.startTurn({ + message: "Persist me", + workspaceId: TEST_WORKSPACE_ID, + }); + // Never attach — wait for the run to end purely via server state. + await waitFor(() => !runtime.isTurnActive(conversationId)); + + const conv = await runtime.findConversation(conversationId, { userId: "usr_default" }); + expect(conv).not.toBeNull(); + + const store = runtime.findConversationStore(); + expect(store).toBeInstanceOf(EventSourcedConversationStore); + const events = await (store as EventSourcedConversationStore).readEvents(conversationId); + expect(events.length).toBeGreaterThan(0); + }); + + it("allows a new turn on the same conversation once idle", async () => { + const { conversationId } = await runtime.startTurn({ + message: "first", + workspaceId: TEST_WORKSPACE_ID, + }); + await awaitTurn(conversationId); + + const again = await runtime.startTurn({ + message: "second", + conversationId, + workspaceId: TEST_WORKSPACE_ID, + }); + expect(again.conversationId).toBe(conversationId); + await awaitTurn(conversationId); + }); +}); diff --git a/test/unit/run-bus.test.ts b/test/unit/run-bus.test.ts new file mode 100644 index 00000000..a11efbc0 --- /dev/null +++ b/test/unit/run-bus.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, it } from "bun:test"; +import { RunInProgressError } from "../../src/runtime/errors.ts"; +import { type BufferedRunEvent, RunBus } from "../../src/runtime/run-bus.ts"; + +function collect(): { events: BufferedRunEvent[]; onEvent: (e: BufferedRunEvent) => void } { + const events: BufferedRunEvent[] = []; + return { events, onEvent: (e) => events.push(e) }; +} + +describe("RunBus", () => { + it("assigns monotonic 1-based sequence numbers", () => { + const bus = new RunBus(); + bus.begin("c1"); + bus.publish("c1", "text.delta", { text: "a" }); + bus.publish("c1", "text.delta", { text: "b" }); + expect(bus.currentSeq("c1")).toBe(2); + }); + + it("throws RunInProgressError when a turn is already active", () => { + const bus = new RunBus(); + bus.begin("c1"); + expect(() => bus.begin("c1")).toThrow(RunInProgressError); + }); + + it("tracks active conversations and clears on end", () => { + const bus = new RunBus(); + bus.begin("c1"); + bus.begin("c2"); + expect(bus.activeConversationIds().sort()).toEqual(["c1", "c2"]); + expect(bus.isActive("c1")).toBe(true); + bus.end("c1", "done"); + expect(bus.isActive("c1")).toBe(false); + expect(bus.activeConversationIds()).toEqual(["c2"]); + }); + + it("replays buffered events on attach, then tails live ones", () => { + const bus = new RunBus(); + bus.begin("c1"); + bus.publish("c1", "text.delta", { text: "one" }); + bus.publish("c1", "text.delta", { text: "two" }); + + // Late subscriber (e.g. a page refresh) — full replay from 0. + const { events, onEvent } = collect(); + bus.attach("c1", 0, onEvent); + expect(events.map((e) => e.seq)).toEqual([1, 2]); + + // Live tail. + bus.publish("c1", "text.delta", { text: "three" }); + expect(events.map((e) => e.seq)).toEqual([1, 2, 3]); + }); + + it("resumes from a given seq without gaps or duplicates", () => { + const bus = new RunBus(); + bus.begin("c1"); + bus.publish("c1", "text.delta", { text: "1" }); + bus.publish("c1", "text.delta", { text: "2" }); + bus.publish("c1", "text.delta", { text: "3" }); + + // Client already rendered through seq 2 — attach for the remainder only. + const { events, onEvent } = collect(); + bus.attach("c1", 2, onEvent); + expect(events.map((e) => e.seq)).toEqual([3]); + }); + + it("delivers terminal status to attachers, including late ones", () => { + const bus = new RunBus(); + bus.begin("c1"); + bus.publish("c1", "text.delta", { text: "hi" }); + + let liveStatus: string | undefined; + bus.attach("c1", 0, () => {}, (s) => { + liveStatus = s; + }); + bus.end("c1", "done"); + expect(liveStatus).toBe("done"); + + // Attaching after the run ended (still within grace) replays + reports end. + const { events, onEvent } = collect(); + let lateStatus: string | undefined; + bus.attach("c1", 0, onEvent, (s) => { + lateStatus = s; + }); + expect(events.map((e) => e.seq)).toEqual([1]); + expect(lateStatus).toBe("done"); + }); + + it("detach stops further delivery", () => { + const bus = new RunBus(); + bus.begin("c1"); + const { events, onEvent } = collect(); + const detach = bus.attach("c1", 0, onEvent); + bus.publish("c1", "text.delta", { text: "a" }); + detach(); + bus.publish("c1", "text.delta", { text: "b" }); + expect(events.map((e) => (e.data as { text: string }).text)).toEqual(["a"]); + }); + + it("cancel aborts the turn signal and marks it cancelled", () => { + const bus = new RunBus(); + const signal = bus.begin("c1"); + expect(signal.aborted).toBe(false); + const ok = bus.cancel("c1"); + expect(ok).toBe(true); + expect(signal.aborted).toBe(true); + expect(bus.getStatus("c1")).toBe("cancelled"); + expect(bus.isActive("c1")).toBe(false); + }); + + it("ignores publish after a run is terminal", () => { + const bus = new RunBus(); + bus.begin("c1"); + bus.end("c1", "done"); + bus.publish("c1", "text.delta", { text: "late" }); + expect(bus.currentSeq("c1")).toBe(0); + }); + + it("does not abort generation on detach (disconnect ≠ cancel)", () => { + const bus = new RunBus(); + const signal = bus.begin("c1"); + const detach = bus.attach("c1", 0, () => {}); + detach(); + // The viewer left, but the turn keeps running. + expect(signal.aborted).toBe(false); + expect(bus.isActive("c1")).toBe(true); + }); + + it("GCs a terminal run after the grace window", async () => { + const bus = new RunBus(10); + bus.begin("c1"); + bus.publish("c1", "x", {}); + bus.end("c1", "done"); + expect(bus.getStatus("c1")).toBe("done"); + await new Promise((r) => setTimeout(r, 30)); + expect(bus.getStatus("c1")).toBeUndefined(); + }); + + it("a new turn replaces a lingering terminal log", () => { + const bus = new RunBus(); + bus.begin("c1"); + bus.publish("c1", "x", {}); + bus.end("c1", "done"); + // New turn — seq restarts, status running. + bus.begin("c1"); + expect(bus.isActive("c1")).toBe(true); + expect(bus.currentSeq("c1")).toBe(0); + }); + + it("attach to an unknown conversation is a safe no-op", () => { + const bus = new RunBus(); + const detach = bus.attach("nope", 0, () => {}); + expect(typeof detach).toBe("function"); + detach(); + }); +}); From 83d88afa9d428692ddeae53ff6b8afc751a9193d Mon Sep 17 00:00:00 2001 From: Mason <31372737+Ovaculos@users.noreply.github.com> Date: Mon, 25 May 2026 10:25:17 -0600 Subject: [PATCH 02/26] fix(conversations): rework auto-title prompt to stop response echo (#253) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The single-string transcript prompt made the fast model pattern-match a conversation to continue and emit the start of the assistant's response as the title (worst on creative/long answers). Use real role turns (user → assistant → user-instruction); the trailing user turn is an unambiguous instruction, not text to continue. Closes #253 Co-Authored-By: Claude Opus 4.7 (1M context) --- src/conversation/auto-title.ts | 15 ++++++++++++--- test/unit/auto-title.test.ts | 20 ++++++++++++++++++++ 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/src/conversation/auto-title.ts b/src/conversation/auto-title.ts index 2330ac92..edda49ad 100644 --- a/src/conversation/auto-title.ts +++ b/src/conversation/auto-title.ts @@ -3,6 +3,13 @@ import type { LanguageModelV3 } from "@ai-sdk/provider"; /** * Generate a short conversation title using the provided model. * Non-blocking — call fire-and-forget after first turn. + * + * The prompt uses real role turns (user → assistant → user-instruction) rather + * than stuffing the whole transcript into one user string. The transcript-in-a- + * string shape made the fast model pattern-match "continue the assistant" and + * echo the response back as the title — worst on creative/long answers (#253). + * A trailing user-role instruction is unambiguously a command, not text to + * continue. */ export async function generateTitle( model: LanguageModelV3, @@ -15,14 +22,16 @@ export async function generateTitle( { role: "system", content: - "Generate a 3-6 word title for this conversation. Return only the title, nothing else.", + "You generate short, descriptive titles for conversations. Reply with the title only.", }, + { role: "user", content: [{ type: "text", text: userMessage.slice(0, 500) }] }, + { role: "assistant", content: [{ type: "text", text: assistantResponse.slice(0, 500) }] }, { role: "user", content: [ { type: "text", - text: `User: ${userMessage.slice(0, 200)}\nAssistant: ${assistantResponse.slice(0, 200)}`, + text: "Reply with a 3-6 word title summarizing this conversation. Output only the title — no quotes, no markdown, no preamble.", }, ], }, @@ -31,7 +40,7 @@ export async function generateTitle( }); const textBlock = result.content.find((b) => b.type === "text"); if (textBlock?.type === "text") { - return textBlock.text.trim(); + return textBlock.text.trim() || fallbackTitle(userMessage); } return fallbackTitle(userMessage); } catch { diff --git a/test/unit/auto-title.test.ts b/test/unit/auto-title.test.ts index f4128de2..3f401b98 100644 --- a/test/unit/auto-title.test.ts +++ b/test/unit/auto-title.test.ts @@ -59,4 +59,24 @@ describe("generateTitle", () => { expect(title.length).toBeLessThanOrEqual(60); expect(longMsg.startsWith(title.trimEnd())).toBe(true); }); + + it("sends the conversation as real role turns ending in an instruction (#253)", async () => { + let captured: unknown; + const model = createMockModel((opts) => { + captured = opts.prompt; + return { content: [{ type: "text", text: "Library Paranoia Joke" }] }; + }); + await generateTitle(model, "Write something funny", "A man walks into a library..."); + const prompt = captured as Array<{ role: string }>; + // system, user(question), assistant(answer), user(instruction) + expect(prompt.map((m) => m.role)).toEqual(["system", "user", "assistant", "user"]); + }); + + it("returns the model's title text (trimmed)", async () => { + const model = createMockModel(() => ({ + content: [{ type: "text", text: " Library Paranoia Joke " }], + })); + const title = await generateTitle(model, "Write something funny", "A man walks in..."); + expect(title).toBe("Library Paranoia Joke"); + }); }); From 549cbd63e7d1a95c82310a49cc39a31acb44f8bc Mon Sep 17 00:00:00 2001 From: Mason <31372737+Ovaculos@users.noreply.github.com> Date: Mon, 25 May 2026 10:25:33 -0600 Subject: [PATCH 03/26] feat(web): per-conversation chat viewer over the server turn stream MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the single shared useChat with a per-conversation slice store. Each conversation owns its own state; a turn's stream writes only into its origin slice, so switching conversations mid-turn never bleeds the response into the destination chat (#254) and switching back shows it still arriving. - chat-store: slice map (LRU-capped) backing useChat via useSyncExternalStore; sendTurn → /v1/chat/start then subscribe via conversation-stream (replay + live tail). Resume reflects server isActive (indicator/Stop survive reload), trims the stale in-flight turn from disk, and drops grace-buffer replay of a finished turn (no duplicate). AbortSignal threaded for cleanup only. - Streaming dots: store streamingIds → hostContext (SlotRenderer) so the list shows live per-row activity; persisted per-tab in sessionStorage and re-probed on reload (dots survive refresh, self-heal when finished). - Reload restore: last-viewed conversation reopened from sessionStorage. - Live title: consume conversation.title SSE → slice title; ChatPanel header prefers the generated title. MessageList lands the view at the bottom. Co-Authored-By: Claude Opus 4.7 (1M context) --- web/src/App.tsx | 6 + web/src/api/client.ts | 77 +- web/src/api/conversation-stream.ts | 157 +++ web/src/bridge/host-extensions.ts | 10 +- web/src/components/AppWithChat.tsx | 62 +- web/src/components/ChatPanel.tsx | 12 +- web/src/components/MessageInput.tsx | 43 +- web/src/components/MessageList.tsx | 15 +- web/src/components/SlotRenderer.tsx | 24 +- web/src/context/ChatContext.tsx | 44 +- web/src/hooks/chat-store.ts | 971 +++++++++++++++++++ web/src/hooks/useChat.ts | 885 ++--------------- web/src/hooks/useEvents.ts | 8 + web/src/lib/active-conversation-storage.ts | 60 ++ web/src/types.ts | 8 + web/test/active-conversation-storage.test.ts | 26 + web/test/chat-store.test.ts | 289 ++++++ web/test/chatBleed.test.tsx | 129 +++ web/test/inlineError.test.tsx | 268 ++--- web/test/streamingState.test.tsx | 460 ++++----- 20 files changed, 2270 insertions(+), 1284 deletions(-) create mode 100644 web/src/api/conversation-stream.ts create mode 100644 web/src/hooks/chat-store.ts create mode 100644 web/src/lib/active-conversation-storage.ts create mode 100644 web/test/active-conversation-storage.test.ts create mode 100644 web/test/chat-store.test.ts create mode 100644 web/test/chatBleed.test.tsx diff --git a/web/src/App.tsx b/web/src/App.tsx index 74ddc997..f9e5e019 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -36,6 +36,7 @@ import { type WorkspaceInfo, WorkspaceProvider, } from "./context/WorkspaceContext"; +import { chatStore } from "./hooks/chat-store"; import { useDataSync } from "./hooks/useDataSync"; import { useEvents } from "./hooks/useEvents"; import { useShell } from "./hooks/useShell"; @@ -227,6 +228,11 @@ function AuthenticatedAppContent({ useEvents(token, wsCtx.activeWorkspace?.id, { onDataChanged, onConfigChanged: () => config.refreshConfig(), + // Auto-title arrived — update the matching conversation's slice so the + // chat panel header reflects it live (routed by conversationId). + onConversationTitle: ({ conversationId, title }) => { + chatStore.setTitle(conversationId, title); + }, // Bundle install / uninstall changes the placement set; refetch // the shell so the sidebar's Apps group reflects the new state // without a page reload. diff --git a/web/src/api/client.ts b/web/src/api/client.ts index bb5d8550..dcfa4e04 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -379,8 +379,14 @@ async function consumeSSEStream(res: Response, onEvent: ChatStreamCallback): Pro } } -/** Streaming chat via SSE. Calls onEvent for each event, resolves when done. */ -export async function streamChat(req: ChatRequest, onEvent: ChatStreamCallback): Promise { +/** Streaming chat via SSE. Calls onEvent for each event, resolves when done. + * `signal` is for caller-driven cleanup (logout / store reset) — NOT + * conversation switching, which keeps the stream alive in the background. */ +export async function streamChat( + req: ChatRequest, + onEvent: ChatStreamCallback, + signal?: AbortSignal, +): Promise { // If a conv-events SSE subscription is open for this conversation, // pass its server-issued subscriber id so the broadcast suppresses // self-echo. Without this, the sender's own tab double-processes @@ -394,6 +400,7 @@ export async function streamChat(req: ChatRequest, onEvent: ChatStreamCallback): credentials: "include", headers: headers(originSubId ? { "X-Origin-Subscriber-Id": originSubId } : undefined), body: JSON.stringify(req), + ...(signal ? { signal } : {}), }); if (res.status === 401) { @@ -420,6 +427,7 @@ export async function streamChatMultipart( req: ChatRequest, files: File[], onEvent: ChatStreamCallback, + signal?: AbortSignal, ): Promise { const formData = new FormData(); formData.append("message", req.message); @@ -450,6 +458,7 @@ export async function streamChatMultipart( credentials: "include", headers: h, body: formData, + ...(signal ? { signal } : {}), }); if (res.status === 401) { @@ -467,6 +476,70 @@ export async function streamChatMultipart( await consumeSSEStream(res, onEvent); } +/** + * Start a server-authoritative turn. Returns the conversation id immediately; + * the turn runs to completion on the server regardless of this client. Watch + * it via `connectConversationStream`. Replaces the streaming `streamChat` path. + */ +export async function startChatTurn(req: ChatRequest): Promise<{ conversationId: string }> { + const res = await fetchWithRefresh(`${API_BASE}/v1/chat/start`, { + method: "POST", + credentials: "include", + headers: headers(), + body: JSON.stringify(req), + }); + if (res.status === 401) throw new ApiClientError("unauthorized", "Unauthorized", 401); + if (!res.ok) { + const body: ApiError = await res + .json() + .catch(() => ({ error: "unknown", message: res.statusText })); + throw new ApiClientError(body.error, body.message, res.status, body.details); + } + return res.json() as Promise<{ conversationId: string }>; +} + +/** Start a server-authoritative turn with file attachments (multipart). */ +export async function startChatTurnMultipart( + req: ChatRequest, + files: File[], +): Promise<{ conversationId: string }> { + const formData = new FormData(); + formData.append("message", req.message); + if (req.conversationId) formData.append("conversationId", req.conversationId); + if (req.model) formData.append("model", req.model); + if (req.appContext) formData.append("appContext", JSON.stringify(req.appContext)); + for (const file of files) formData.append("files", file, file.name); + + const h: Record = {}; + if (authToken && authToken !== "__cookie__") h.Authorization = `Bearer ${authToken}`; + if (activeWorkspaceId) h["X-Workspace-Id"] = activeWorkspaceId; + + const res = await fetchWithRefresh(`${API_BASE}/v1/chat/start`, { + method: "POST", + credentials: "include", + headers: h, + body: formData, + }); + if (res.status === 401) throw new ApiClientError("unauthorized", "Unauthorized", 401); + if (!res.ok) { + const body: ApiError = await res + .json() + .catch(() => ({ error: "unknown", message: res.statusText })); + throw new ApiClientError(body.error, body.message, res.status, body.details); + } + return res.json() as Promise<{ conversationId: string }>; +} + +/** Explicitly stop an in-flight turn (the Stop button). */ +export async function cancelChatTurn(conversationId: string): Promise { + await fetchWithRefresh( + `${API_BASE}/v1/conversations/${encodeURIComponent(conversationId)}/cancel`, + { method: "POST", credentials: "include", headers: headers() }, + ).catch(() => { + // Best-effort — the turn may have already finished. + }); +} + // --------------------------------------------------------------------------- // Health // --------------------------------------------------------------------------- diff --git a/web/src/api/conversation-stream.ts b/web/src/api/conversation-stream.ts new file mode 100644 index 00000000..c7feaf20 --- /dev/null +++ b/web/src/api/conversation-stream.ts @@ -0,0 +1,157 @@ +/** + * Conversation turn-stream client (server-authoritative streaming). + * + * Connects to GET /v1/conversations/:id/events?afterSeq=N. The server replays + * the in-flight turn from the RunBus (events with seq > afterSeq), then tails + * live. This is the ONE rendering path: send, resume-after-refresh, switch + * back, and cross-tab all watch the same stream. + * + * Each frame carries a sequence number in the SSE `id:` line. We track the + * highest seq seen and reconnect with `afterSeq=`, so a dropped + * connection resumes seamlessly with no gap or duplication — no full reload. + */ + +import { refreshSession } from "./client"; + +export interface ConversationStreamOptions { + conversationId: string; + apiBase?: string; + token?: string; + /** Highest seq the caller has already applied (resume point). Default 0. */ + afterSeq?: number; + /** Called for each turn event. `seq` is monotonic within a turn. */ + onEvent: (type: string, data: unknown, seq: number) => void; + /** Called once per (re)connect with the server's current turn state, before + * any replayed events. Lets the caller trim a stale in-flight turn. */ + onSubscribed?: (info: { isActive: boolean; activeSeq: number }) => void; + /** Called on unrecoverable error (403/404/auth). */ + onError?: (error: Error) => void; +} + +export interface ConversationStreamConnection { + close(): void; +} + +const INITIAL_BACKOFF_MS = 1_000; +const MAX_BACKOFF_MS = 30_000; +const BACKOFF_MULTIPLIER = 2; + +export function connectConversationStream( + options: ConversationStreamOptions, +): ConversationStreamConnection { + const { conversationId, apiBase = "", token, onEvent, onSubscribed, onError } = options; + + let closed = false; + let abortController: AbortController | null = null; + let reconnectTimer: ReturnType | null = null; + let backoff = INITIAL_BACKOFF_MS; + // Track the resume point so a reconnect picks up exactly where we left off. + let lastSeq = options.afterSeq ?? 0; + + async function connect(): Promise { + if (closed) return; + abortController = new AbortController(); + const hdrs: Record = {}; + if (token && token !== "__cookie__") hdrs.Authorization = `Bearer ${token}`; + + try { + const url = `${apiBase}/v1/conversations/${encodeURIComponent(conversationId)}/events?afterSeq=${lastSeq}`; + const res = await fetch(url, { + headers: hdrs, + credentials: "include", + signal: abortController.signal, + }); + + if (res.status === 401) { + const refreshed = await refreshSession(); + if (refreshed) return void scheduleReconnect(); + onError?.(new Error("Conversation stream auth failed after token refresh")); + return; + } + if (!res.ok) { + if (res.status === 403 || res.status === 404) { + onError?.(new Error(`Conversation stream access denied: ${res.status}`)); + return; + } + throw new Error(`Conversation stream failed: ${res.status} ${res.statusText}`); + } + + backoff = INITIAL_BACKOFF_MS; + const reader = res.body?.getReader(); + if (!reader) throw new Error("No response body"); + + const decoder = new TextDecoder(); + let buffer = ""; + let currentEvent = ""; + let currentSeq: number | null = null; + + for (;;) { + const { done, value } = await reader.read(); + if (done || closed) break; + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() ?? ""; + + for (const line of lines) { + if (line.startsWith("event: ")) { + currentEvent = line.slice(7).trim(); + } else if (line.startsWith("id: ")) { + const n = Number.parseInt(line.slice(4).trim(), 10); + currentSeq = Number.isFinite(n) ? n : null; + } else if (line.startsWith("data: ") && currentEvent) { + try { + const data = JSON.parse(line.slice(6)); + if (currentEvent === "subscribed") { + const info = data as { isActive?: boolean; activeSeq?: number }; + onSubscribed?.({ + isActive: info.isActive ?? false, + activeSeq: info.activeSeq ?? 0, + }); + } else { + const seq = currentSeq ?? 0; + if (seq > lastSeq) lastSeq = seq; + onEvent(currentEvent, data, seq); + } + } catch { + // Skip malformed frames. + } + currentEvent = ""; + currentSeq = null; + } + } + } + + if (!closed) scheduleReconnect(); + } catch (err) { + if (closed) return; + if (err instanceof DOMException && err.name === "AbortError") return; + if (err instanceof Error && err.message.includes("403")) { + onError?.(err); + return; + } + scheduleReconnect(); + } + } + + function scheduleReconnect(): void { + if (closed) return; + reconnectTimer = setTimeout(() => { + backoff = Math.min(backoff * BACKOFF_MULTIPLIER, MAX_BACKOFF_MS); + connect(); + }, backoff); + } + + connect(); + + return { + close() { + closed = true; + if (reconnectTimer) { + clearTimeout(reconnectTimer); + reconnectTimer = null; + } + abortController?.abort(); + abortController = null; + }, + }; +} diff --git a/web/src/bridge/host-extensions.ts b/web/src/bridge/host-extensions.ts index f8f810dd..57a5101b 100644 --- a/web/src/bridge/host-extensions.ts +++ b/web/src/bridge/host-extensions.ts @@ -31,11 +31,18 @@ export type WorkspaceForHostContext = { id: string; name: string } | null; export function buildHostExtensions( workspace: WorkspaceForHostContext, forceRefresh = false, + streamingConversationIds: string[] = [], ): Record { const ext: Record = workspace ? { workspace: { id: workspace.id, name: workspace.name } } : {}; if (forceRefresh) ext.forceRefresh = true; + // Conversations with an in-flight assistant turn in this browser tab. Apps + // (e.g. the conversations list) render a live "streaming" affordance per + // row. Ephemeral, tab-local — not persisted, not from the server. + if (streamingConversationIds.length > 0) { + ext.streamingConversationIds = streamingConversationIds; + } return ext; } @@ -47,10 +54,11 @@ export function buildHostExtensions( export function buildHostContext( mode: "light" | "dark", workspace: WorkspaceForHostContext, + streamingConversationIds: string[] = [], ): Record { const tokens = getThemeTokens(mode); return { - ...buildHostExtensions(workspace), + ...buildHostExtensions(workspace, false, streamingConversationIds), theme: mode, styles: { variables: tokens }, }; diff --git a/web/src/components/AppWithChat.tsx b/web/src/components/AppWithChat.tsx index e8502e51..e2d22834 100644 --- a/web/src/components/AppWithChat.tsx +++ b/web/src/components/AppWithChat.tsx @@ -1,16 +1,36 @@ import { MessageSquare } from "lucide-react"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; import { useLocation } from "react-router-dom"; import type { UiChatContext } from "../bridge/types"; import { useChatContext } from "../context/ChatContext"; import { useChatPanelContext } from "../context/ChatPanelContext"; import { useSidebar } from "../context/SidebarContext"; +import { chatStore } from "../hooks/chat-store"; +import { + getSavedConversationId, + getSavedStreamingIds, + setSavedConversationId, + setSavedStreamingIds, +} from "../lib/active-conversation-storage"; import type { AppContext, PlacementEntry } from "../types"; import type { ChatPanelRef } from "./ChatPanel"; import { ChatPanel } from "./ChatPanel"; import { ResizeHandle } from "./ResizeHandle"; import { SlotRenderer } from "./SlotRenderer"; +/** + * Module-once guard: restore conversation state only on a fresh page load, not + * on every client-side app navigation (which remounts AppWithChat). A page + * reload resets the module, re-arming the restore. + */ +let restoredLastConversation = false; +/** + * Snapshot of the persisted streaming-id set captured at module-eval time + * (page load), before any persist effect overwrites sessionStorage with the + * post-reload (empty) set. + */ +const initialSavedStreamingIds = getSavedStreamingIds(); + function useIsMobile(): boolean { const [isMobile, setIsMobile] = useState( () => typeof window !== "undefined" && window.innerWidth < 768, @@ -59,19 +79,53 @@ export function AppWithChat({ placement, onNavigate, forceRefresh }: AppWithChat } }, [location.pathname]); - // Deep-link: open chat from ?chat= on mount + // Deep-link: open chat from ?chat= on mount. Otherwise, on a + // fresh page load, reopen the last-viewed conversation (per-tab, via + // sessionStorage) so an in-flight turn's stream/indicator resumes — + // loadConversation re-subscribes and the server's `isActive` drives the + // bubble. Module-once so app-to-app navigation doesn't re-trigger it. const deepLinkHandled = useRef(false); // biome-ignore lint/correctness/useExhaustiveDependencies: intentionally runs only on mount useEffect(() => { if (deepLinkHandled.current) return; deepLinkHandled.current = true; - const params = new URLSearchParams(window.location.search); - const chatId = params.get("chat"); + const chatId = new URLSearchParams(window.location.search).get("chat"); if (chatId) { openPanel(chatId); + return; + } + if (!restoredLastConversation) { + restoredLastConversation = true; + const saved = getSavedConversationId(); + // Hydrate without forcing the panel open — its visibility is restored + // independently from ChatPanelContext's persisted state. When the panel + // is (re)opened it shows this conversation. + if (saved) void chat.loadConversation(saved); + // Restore background streaming dots: probe each conversation that was + // generating before reload. Still-active ones light up; finished ones + // self-heal (probe → not active → no dot). + for (const id of initialSavedStreamingIds) { + if (id !== saved) chatStore.probeConversation(id); + } } }, []); + // Persist the active conversation id (per-tab) so a reload can reopen it. + // Cleared automatically when a new/draft chat is active (conversationId null). + useEffect(() => { + setSavedConversationId(chat.conversationId); + }, [chat.conversationId]); + + // Persist the set of conversations with an in-flight turn so a reload can + // restore their streaming dots (re-probed against the server above). + const streamingIds = useSyncExternalStore( + chatStore.subscribeStreamingIds, + chatStore.getStreamingIds, + ); + useEffect(() => { + setSavedStreamingIds(streamingIds); + }, [streamingIds]); + // Unread tracking: count assistant messages added while panel is closed const lastSeenAssistantCount = useRef(0); const [buttonVisible, setButtonVisible] = useState(() => panelState === "closed"); diff --git a/web/src/components/ChatPanel.tsx b/web/src/components/ChatPanel.tsx index 2885e773..a0b32d23 100644 --- a/web/src/components/ChatPanel.tsx +++ b/web/src/components/ChatPanel.tsx @@ -48,11 +48,12 @@ export const ChatPanel = forwardRef(function ChatP const inputWrapperRef = useRef(null); const [showShortcuts, setShowShortcuts] = useState(false); const [copiedId, setCopiedId] = useState(false); - const { conversationId, streamingState, preparingTool } = useChatContext(); + const { conversationId, title, streamingState, preparingTool, stop } = useChatContext(); const { currentUserId, participantMap } = useChatConfigContext(); - // Derive a title from the first user message, stripping markdown syntax - const rawTitle = messages.find((m) => m.role === "user")?.content || null; + // Prefer the server-generated title (updates live when it arrives); fall back + // to the first user message, stripping markdown syntax, until then. + const rawTitle = title ?? messages.find((m) => m.role === "user")?.content ?? null; const plainTitle = rawTitle ? rawTitle .replace(/^#{1,6}\s+/gm, "") // headings @@ -65,9 +66,9 @@ export const ChatPanel = forwardRef(function ChatP .replace(/\n/g, " ") // newlines to spaces .trim() : null; - const conversationTitle = plainTitle?.slice(0, 30) || null; + const conversationTitle = plainTitle?.slice(0, 40) || null; const displayTitle = conversationTitle - ? plainTitle && plainTitle.length > 30 + ? plainTitle && plainTitle.length > 40 ? `${conversationTitle}…` : conversationTitle : null; @@ -230,6 +231,7 @@ export const ChatPanel = forwardRef(function ChatP disabled={isStreaming} onNewConversation={handleNewChat} streamingState={streamingState} + onStop={stop} /> diff --git a/web/src/components/MessageInput.tsx b/web/src/components/MessageInput.tsx index 34a73449..2aedda91 100644 --- a/web/src/components/MessageInput.tsx +++ b/web/src/components/MessageInput.tsx @@ -1,4 +1,4 @@ -import { ArrowUp, Paperclip } from "lucide-react"; +import { ArrowUp, Paperclip, Square } from "lucide-react"; import { useCallback, useEffect, useRef, useState } from "react"; import type { StreamingState } from "../hooks/useChat"; import { FileAttachmentChips } from "./FileAttachmentChips"; @@ -11,6 +11,9 @@ interface MessageInputProps { onNewConversation?: () => void; /** Drives the ambient "breathing" border while a turn is in flight. */ streamingState?: StreamingState; + /** Stop the in-flight turn. When provided, the send button becomes a Stop + * button while a turn is streaming. */ + onStop?: () => void; } export function MessageInput({ @@ -18,6 +21,7 @@ export function MessageInput({ disabled, onNewConversation, streamingState, + onStop, }: MessageInputProps) { const [text, setText] = useState(""); const [isFocused, setIsFocused] = useState(false); @@ -218,19 +222,30 @@ export function MessageInput({ - + {disabled && onStop ? ( + + ) : ( + + )} diff --git a/web/src/components/MessageList.tsx b/web/src/components/MessageList.tsx index e2871d86..fae1e220 100644 --- a/web/src/components/MessageList.tsx +++ b/web/src/components/MessageList.tsx @@ -180,15 +180,22 @@ function useSmartScroll(messages: ChatMessage[]) { prevConversationKeyRef.current = conversationKey; prevMessageCountRef.current = messages.length; - // Conversation loaded (different conversation or first load with history) + // Conversation loaded (different conversation or first load with history): + // land at the bottom (most recent turn), like ChatGPT/Claude. if (conversationKey !== prevKey && messages.length > 1) { - // Use double-rAF to ensure DOM has rendered the messages + // Use double-rAF to ensure the DOM has rendered the messages. Scroll the + // last real message to the viewport bottom (not the trailing 60vh + // spacer / bottomRef, which would leave the last turn off-screen). requestAnimationFrame(() => { requestAnimationFrame(() => { - scrollRef.current?.scrollTo({ top: 0, behavior: "instant" }); + const container = scrollRef.current; + const inner = container?.firstElementChild; + const lastMsg = inner?.children[messages.length - 1] as HTMLElement | undefined; + if (lastMsg) lastMsg.scrollIntoView({ behavior: "instant", block: "end" }); + else container?.scrollTo({ top: container.scrollHeight, behavior: "instant" }); }); }); - setIsAtBottom(false); + setIsAtBottom(true); return; } diff --git a/web/src/components/SlotRenderer.tsx b/web/src/components/SlotRenderer.tsx index 4ac72025..a5102677 100644 --- a/web/src/components/SlotRenderer.tsx +++ b/web/src/components/SlotRenderer.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef } from "react"; +import { useEffect, useRef, useSyncExternalStore } from "react"; import { getResources, uiPathFromUri } from "../api/client"; import type { BridgeHandle } from "../bridge/bridge"; import { createBridge } from "../bridge/bridge"; @@ -7,6 +7,7 @@ import { createAppIframe } from "../bridge/iframe"; import type { UiChatContext } from "../bridge/types"; import { useTheme } from "../context/ThemeContext"; import { useWorkspaceContext } from "../context/WorkspaceContext"; +import { chatStore } from "../hooks/chat-store"; import type { PlacementEntry } from "../types"; interface SlotRendererProps { @@ -61,6 +62,17 @@ export function SlotRenderer({ const forceRefreshRef = useRef(forceRefresh); forceRefreshRef.current = forceRefresh; + // Conversations currently streaming an assistant turn in this tab. Pushed + // into hostContext so the conversations list can show a per-row indicator. + // The store identity is stable between membership changes, so this only + // re-pushes when a conversation starts/stops streaming — not per delta. + const streamingIds = useSyncExternalStore( + chatStore.subscribeStreamingIds, + chatStore.getStreamingIds, + ); + const streamingIdsRef = useRef(streamingIds); + streamingIdsRef.current = streamingIds; + const filtered = routeFilter ? placements.filter((p) => p.route === routeFilter) : placements; // Stable key: only re-mount iframes when the actual placements change @@ -111,7 +123,11 @@ export function SlotRenderer({ onNavigate: (...args) => onNavigateRef.current?.(...args), onPromptAction: (...args) => onPromptActionRef.current?.(...args), getHostExtensions: () => - buildHostExtensions(workspaceRef.current, forceRefreshRef.current), + buildHostExtensions( + workspaceRef.current, + forceRefreshRef.current, + streamingIdsRef.current, + ), }); bridges.push(bridge); } catch (err) { @@ -140,11 +156,11 @@ export function SlotRenderer({ // mounted; apps that observe `useHostContext()` (or `useTheme()`) re-render // and refetch workspace-scoped data without losing local state. useEffect(() => { - const ctx = buildHostContext(mode, activeWorkspace); + const ctx = buildHostContext(mode, activeWorkspace, streamingIds); for (const bridge of bridgesRef.current) { bridge.setHostContext(ctx); } - }, [mode, activeWorkspace]); + }, [mode, activeWorkspace, streamingIds]); if (filtered.length === 0) return null; diff --git a/web/src/context/ChatContext.tsx b/web/src/context/ChatContext.tsx index 16c590c3..442e2157 100644 --- a/web/src/context/ChatContext.tsx +++ b/web/src/context/ChatContext.tsx @@ -1,9 +1,17 @@ import type { ReactNode } from "react"; -import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react"; +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import { callTool } from "../api/client"; +import { chatStore } from "../hooks/chat-store"; import type { UseChatReturn } from "../hooks/useChat"; import { useChat } from "../hooks/useChat"; -import { useConversationEvents } from "../hooks/useConversationEvents"; import type { AppContext, ConfigInfo } from "../types"; // --------------------------------------------------------------------------- @@ -59,6 +67,19 @@ export function ChatProvider({ }: ChatProviderProps) { const chat = useChat(initialConversationId, currentUserId); + // Drop every cached conversation slice when the signed-in user changes + // (logout → login as someone else in the same tab). Conversations are + // user-scoped, so a workspace switch must NOT reset — only an identity + // change. The store is a module singleton that outlives this provider's + // remounts, so stale slices would otherwise leak across users. + const prevUserRef = useRef(currentUserId); + useEffect(() => { + if (prevUserRef.current !== currentUserId) { + chatStore.reset(); + prevUserRef.current = currentUserId; + } + }, [currentUserId]); + // Dev helper: window.__nb.simulateError("some error message") useEffect(() => { if (!import.meta.env.DEV) return; @@ -152,21 +173,10 @@ export function ChatProvider({ } }, []); - // Same-user cross-tab sync (Stage 1 single-owner). Stage 4 widens - // the audience when sharing returns. - useConversationEvents(chat.conversationId, { - onRemoteUserMessage: (data) => { - chat.injectRemoteUserMessage(data.userId, data.displayName, data.content); - }, - onRemoteStreamEvent: (type, data) => { - chat.processRemoteStreamEvent(type, data); - }, - onReconnect: () => { - if (chat.conversationId) { - chat.loadConversation(chat.conversationId); - } - }, - }); + // Cross-tab / refresh sync is now handled by the per-conversation turn + // stream itself (server-authoritative): every viewer attaches to + // GET /v1/conversations/:id/events, which replays the in-flight turn and + // tails live. No separate remote-event bridge needed. const wrappedSendMessage = useCallback( (text: string, appContext?: AppContext, files?: File[]) => { diff --git a/web/src/hooks/chat-store.ts b/web/src/hooks/chat-store.ts new file mode 100644 index 00000000..0d5a4ca2 --- /dev/null +++ b/web/src/hooks/chat-store.ts @@ -0,0 +1,971 @@ +import { + callTool, + cancelChatTurn, + getAuthToken, + startChatTurn, + startChatTurnMultipart, +} from "../api/client"; +import { + type ConversationStreamConnection, + connectConversationStream, +} from "../api/conversation-stream"; +import { formatSendError } from "../api/format-error"; +import type { + AppContext, + ChatRequest, + ChatResult, + LlmDoneEvent, + ReasoningDeltaEvent, + StreamErrorEvent, + TextDeltaEvent, + ToolDoneEvent, + ToolPreparingEvent, + ToolStartEvent, +} from "../types"; + +// =========================================================================== +// Public display types (shared across the chat UI). These live here — not in +// useChat — because the slice store is the lowest layer that owns them and +// `useChat` re-exports them for backward-compatible imports. +// =========================================================================== + +export type StreamingState = + | null + | "thinking" + | "streaming" + | "preparing" + | "working" + | "analyzing"; + +/** Identifies the tool the model is currently building a call for. */ +export interface PreparingTool { + id: string; + name: string; +} + +/** Typed tool result shape forwarded through the bridge. */ +export interface ToolResultForUI { + content: Array<{ type: string; text?: string; [key: string]: unknown }>; + structuredContent?: Record; + isError: boolean; +} + +/** Tool call with UI state for streaming display. */ +export interface ToolCallDisplay { + id: string; + name: string; + status: "running" | "done" | "error"; + ok?: boolean; + ms?: number; + resourceUri?: string; + resourceLinks?: Array<{ + uri: string; + name?: string; + mimeType?: string; + description?: string; + }>; + result?: ToolResultForUI; + input?: Record; + appName?: string; +} + +/** A block in the assistant message stream — text, reasoning, or tool group. */ +export type ContentBlock = + | { type: "text"; text: string } + | { type: "reasoning"; text: string } + | { type: "tool"; toolCalls: ToolCallDisplay[] }; + +/** Live iteration progress during streaming. */ +export interface IterationProgress { + n: number; + inputTokens: number; + outputTokens: number; +} + +/** File metadata attached to a message. */ +export interface MessageFileAttachment { + id: string; + filename: string; + mimeType: string; + size: number; + extracted: boolean; +} + +/** A chat message with ordered content blocks for display. */ +export interface ChatMessage { + role: "user" | "assistant"; + content: string; + blocks?: ContentBlock[]; + toolCalls?: ToolCallDisplay[]; + iteration?: IterationProgress; + timestamp?: string; + userId?: string; + files?: MessageFileAttachment[]; + stopReason?: string; + error?: string; + usage?: { + inputTokens: number; + outputTokens: number; + cacheReadTokens?: number; + cacheWriteTokens?: number; + reasoningTokens?: number; + model: string; + llmMs: number; + }; +} + +/** Conversation-level metadata (Stage 1: single-owner only). */ +export interface LoadedConversationMeta { + ownerId?: string; +} + +// =========================================================================== +// Snapshot — the immutable view a React component renders for one conversation. +// =========================================================================== + +export interface ChatSnapshot { + conversationId: string | null; + /** Server-generated conversation title (null until generated/loaded). */ + title: string | null; + messages: ChatMessage[]; + isStreaming: boolean; + streamingState: StreamingState; + preparingTool: PreparingTool | null; + meta: LoadedConversationMeta | null; + error: string | null; +} + +const EMPTY_MESSAGES: ChatMessage[] = []; +const EMPTY_SNAPSHOT: ChatSnapshot = { + conversationId: null, + title: null, + messages: EMPTY_MESSAGES, + isStreaming: false, + streamingState: null, + preparingTool: null, + meta: null, + error: null, +}; + +// =========================================================================== +// Slice — mutable per-conversation viewer state. +// +// The server is authoritative: a turn runs to completion server-side and its +// events are published to a per-conversation stream. This slice is a *view* +// over that stream plus the persisted history. Switching away / refreshing +// just detaches; re-attaching replays the in-flight turn (issue #254 + +// server-authoritative streaming follow-up). +// =========================================================================== + +interface ConversationSlice { + keys: Set; + conversationId: string | null; + title: string | null; + messages: ChatMessage[]; + isStreaming: boolean; + streamingState: StreamingState; + preparingTool: PreparingTool | null; + meta: LoadedConversationMeta | null; + error: string | null; + // streaming scratch + blocks: ContentBlock[]; + toolCalls: ToolCallDisplay[]; + iteration?: IterationProgress; + // live subscription to the server turn stream (null when detached) + connection: ConversationStreamConnection | null; + /** The next streamed `user.message` echoes a turn we optimistically added — + * consume it instead of appending a duplicate. */ + pendingEcho: boolean; + /** First `subscribed` frame of a resume should trim a stale in-flight turn + * from disk history (the replay rebuilds it). */ + resumeOnSubscribe: boolean; + /** True once full history is loaded (loadConversation) or the conversation + * was authored in this session (sendTurn / new draft). A dot-only probe + * leaves it false so opening the conversation still fetches full history. */ + hydrated: boolean; + lastActiveAt: number; + snapshot: ChatSnapshot; +} + +export interface StartTurnHooks { + onConversationId?: (id: string) => void; +} + +export interface StartTurnParams { + text: string; + appContext?: AppContext; + model?: string; + files?: File[]; + currentUserId?: string; +} + +// --------------------------------------------------------------------------- +// Pure helpers +// --------------------------------------------------------------------------- + +function cloneBlocks(blocks: ContentBlock[]): ContentBlock[] { + return blocks.map((b) => { + if (b.type === "tool") return { ...b, toolCalls: [...b.toolCalls] }; + return { ...b }; + }); +} + +function textFromBlocks(blocks: ContentBlock[]): string { + return blocks + .filter((b): b is ContentBlock & { type: "text" } => b.type === "text") + .map((b) => b.text) + .join(""); +} + +function wrapStringResult(text: string, isError = false): ToolResultForUI { + return { content: [{ type: "text", text }], isError }; +} + +const updateTool = + (evt: ToolDoneEvent) => + (tc: ToolCallDisplay): ToolCallDisplay => + tc.id === evt.id + ? { + ...tc, + status: evt.ok ? ("done" as const) : ("error" as const), + ok: evt.ok, + ms: evt.ms, + resourceUri: tc.resourceUri ?? evt.resourceUri, + resourceLinks: + evt.resourceLinks != null && evt.resourceLinks.length > 0 + ? evt.resourceLinks + : tc.resourceLinks, + result: evt.result != null ? (evt.result as ToolResultForUI) : tc.result, + } + : tc; + +// --------------------------------------------------------------------------- +// Key helpers +// --------------------------------------------------------------------------- + +const DRAFT_PREFIX = "draft:"; +let draftCounter = 0; + +export function freshDraftKey(): string { + draftCounter += 1; + return `${DRAFT_PREFIX}${draftCounter}`; +} + +export function isDraftKey(key: string): boolean { + return key.startsWith(DRAFT_PREFIX); +} + +// --------------------------------------------------------------------------- +// Store +// --------------------------------------------------------------------------- + +const MAX_SLICES = 30; + +export interface ChatStore { + ensureSlice(key: string, opts?: { conversationId?: string | null }): void; + getSnapshot(key: string): ChatSnapshot; + subscribeSlice(key: string, cb: () => void): () => void; + getStreamingIds(): string[]; + subscribeStreamingIds(cb: () => void): () => void; + markActive(key: string): void; + markInactive(key: string): void; + /** Send a message: start a server turn, then watch its stream. */ + sendTurn(key: string, params: StartTurnParams, hooks?: StartTurnHooks): Promise; + /** Load persisted history and attach to any in-flight turn. */ + loadConversation(id: string): Promise; + /** Probe whether a conversation is generating (restores dots on reload), + * without fetching message history. */ + probeConversation(id: string): void; + /** Set a conversation's title (from the live `conversation.title` SSE). + * No-op if the conversation has no slice in this tab. */ + setTitle(conversationId: string, title: string): void; + /** Stop an in-flight turn (the only thing that aborts generation). */ + cancelTurn(key: string): void; + retryLastMessage(key: string): string | null; + simulateError(key: string, message: string): void; + reset(): void; + sliceCount(): number; +} + +export function createChatStore(): ChatStore { + const byKey = new Map(); + const allSlices = new Set(); + const listeners = new Map void>>(); + const activeCounts = new Map(); + + let streamingIds: string[] = []; + const streamingListeners = new Set<() => void>(); + + // -- snapshot + notification -- + + function buildSnapshot(slice: ConversationSlice): ChatSnapshot { + return { + conversationId: slice.conversationId, + title: slice.title, + messages: slice.messages, + isStreaming: slice.isStreaming, + streamingState: slice.streamingState, + preparingTool: slice.preparingTool, + meta: slice.meta, + error: slice.error, + }; + } + + function notifyKey(key: string): void { + const set = listeners.get(key); + if (!set) return; + for (const cb of set) cb(); + } + + function recomputeStreamingIds(): void { + const ids = new Set(); + for (const slice of allSlices) { + if (slice.isStreaming && slice.conversationId) ids.add(slice.conversationId); + } + const next = [...ids].sort(); + if (next.length !== streamingIds.length || next.some((id, i) => id !== streamingIds[i])) { + streamingIds = next; + for (const cb of streamingListeners) cb(); + } + } + + function commit(slice: ConversationSlice): void { + slice.snapshot = buildSnapshot(slice); + for (const key of slice.keys) notifyKey(key); + recomputeStreamingIds(); + } + + // -- slice lifecycle -- + + function isActive(slice: ConversationSlice): boolean { + for (const key of slice.keys) { + if ((activeCounts.get(key) ?? 0) > 0) return true; + } + return false; + } + + function removeSlice(slice: ConversationSlice): void { + slice.connection?.close(); + slice.connection = null; + for (const key of slice.keys) byKey.delete(key); + allSlices.delete(slice); + } + + function evict(): void { + if (allSlices.size <= MAX_SLICES) return; + const idle = [...allSlices] + .filter((s) => !s.isStreaming && !isActive(s)) + .sort((a, b) => a.lastActiveAt - b.lastActiveAt); + let over = allSlices.size - MAX_SLICES; + for (const s of idle) { + if (over <= 0) break; + removeSlice(s); + over -= 1; + } + } + + function createSlice(key: string, conversationId: string | null): ConversationSlice { + const slice: ConversationSlice = { + keys: new Set([key]), + conversationId, + title: null, + messages: [], + isStreaming: false, + streamingState: null, + preparingTool: null, + meta: null, + error: null, + blocks: [], + toolCalls: [], + iteration: undefined, + connection: null, + pendingEcho: false, + resumeOnSubscribe: false, + // A fresh draft is fully "loaded" (empty IS its full history); a slice + // keyed by a real conversation id starts unhydrated until fetched. + hydrated: isDraftKey(key), + lastActiveAt: Date.now(), + snapshot: EMPTY_SNAPSHOT, + }; + slice.snapshot = buildSnapshot(slice); + byKey.set(key, slice); + allSlices.add(slice); + evict(); + return slice; + } + + function ensureSlice(key: string, opts?: { conversationId?: string | null }): void { + const existing = byKey.get(key); + if (existing) { + existing.lastActiveAt = Date.now(); + return; + } + const convId = + opts && "conversationId" in opts + ? (opts.conversationId ?? null) + : isDraftKey(key) + ? null + : key; + createSlice(key, convId); + } + + function aliasSlice(slice: ConversationSlice, conversationId: string): void { + if (slice.keys.has(conversationId)) return; + slice.keys.add(conversationId); + byKey.set(conversationId, slice); + } + + // -- streaming scratch -- + + function resetScratch(slice: ConversationSlice): void { + slice.blocks = []; + slice.toolCalls = []; + slice.iteration = undefined; + } + + function assistantFromScratch(slice: ConversationSlice): ChatMessage { + return { + role: "assistant", + content: textFromBlocks(slice.blocks), + blocks: cloneBlocks(slice.blocks), + toolCalls: [...slice.toolCalls], + iteration: slice.iteration ? { ...slice.iteration } : undefined, + }; + } + + function flush(slice: ConversationSlice): void { + const updated = [...slice.messages]; + updated[updated.length - 1] = assistantFromScratch(slice); + slice.messages = updated; + commit(slice); + } + + /** Drop the trailing in-flight turn (last user message + anything after). */ + function trimTrailingTurn(slice: ConversationSlice): void { + for (let i = slice.messages.length - 1; i >= 0; i--) { + if (slice.messages[i].role === "user") { + slice.messages = slice.messages.slice(0, i); + return; + } + } + } + + // -- subscription -- + + function closeConnection(slice: ConversationSlice): void { + slice.connection?.close(); + slice.connection = null; + } + + function openConnection(slice: ConversationSlice, conversationId: string, resume: boolean): void { + closeConnection(slice); + slice.resumeOnSubscribe = resume; + // When a resume finds no active turn, the server may still replay the most + // recent (already-finished) turn from its grace buffer. Those events would + // re-append a turn that's already in the loaded disk history → duplicate. + // Drop them once we know this connection isn't watching a live turn. + let dropEvents = false; + slice.connection = connectConversationStream({ + conversationId, + token: getAuthToken() ?? undefined, + afterSeq: 0, + onSubscribed: (info) => { + if (slice.resumeOnSubscribe) { + slice.resumeOnSubscribe = false; + if (info.isActive) { + // A turn is in flight — trim the stale in-flight turn loaded from + // disk; the RunBus replay rebuilds it from the top. Reflect the + // server's "is generating" truth immediately so the streaming + // indicator (and Stop button) show on resume without waiting for + // the first replayed event. + trimTrailingTurn(slice); + resetScratch(slice); + slice.isStreaming = true; + if (!slice.streamingState) slice.streamingState = "thinking"; + commit(slice); + } else if (!slice.isStreaming) { + // Nothing in flight and we're not sending — ignore the trailing + // grace-buffer replay (already in disk history) and detach. + dropEvents = true; + closeConnection(slice); + } + } + }, + onEvent: (type, data) => { + if (dropEvents) return; + applyStreamEvent(slice, type, data); + }, + onError: () => { + // Leave the slice intact; the persisted history still renders. + }, + }); + } + + // -- stream reducer -- + + function applyStreamEvent(slice: ConversationSlice, type: string, data: unknown): void { + switch (type) { + case "user.message": { + const evt = data as { content: string; userId?: string; timestamp?: string }; + resetScratch(slice); + if (slice.pendingEcho) { + // Our optimistic user message + assistant placeholder are already in + // place; the deltas will fill the placeholder. + slice.pendingEcho = false; + } else { + const userMsg: ChatMessage = { + role: "user", + content: evt.content, + ...(evt.timestamp ? { timestamp: evt.timestamp } : {}), + ...(evt.userId ? { userId: evt.userId } : {}), + }; + const assistantMsg: ChatMessage = { + role: "assistant", + content: "", + blocks: [], + toolCalls: [], + timestamp: new Date().toISOString(), + }; + slice.messages = [...slice.messages, userMsg, assistantMsg]; + } + slice.isStreaming = true; + slice.streamingState = "thinking"; + commit(slice); + break; + } + case "chat.start": { + const evt = data as { conversationId: string }; + if (evt.conversationId && slice.conversationId !== evt.conversationId) { + slice.conversationId = evt.conversationId; + aliasSlice(slice, evt.conversationId); + commit(slice); + } + break; + } + case "text.delta": { + const evt = data as TextDeltaEvent; + slice.streamingState = "streaming"; + slice.preparingTool = null; + const last = slice.blocks[slice.blocks.length - 1]; + if (last && last.type === "text") last.text += evt.text; + else slice.blocks.push({ type: "text", text: evt.text }); + flush(slice); + break; + } + case "reasoning.delta": { + const evt = data as ReasoningDeltaEvent; + slice.streamingState = "streaming"; + slice.preparingTool = null; + const last = slice.blocks[slice.blocks.length - 1]; + if (last && last.type === "reasoning") last.text += evt.text; + else slice.blocks.push({ type: "reasoning", text: evt.text }); + flush(slice); + break; + } + case "tool.preparing": { + const evt = data as ToolPreparingEvent; + slice.streamingState = "preparing"; + slice.preparingTool = { id: evt.id, name: evt.name }; + commit(slice); + break; + } + case "tool.preparing.done": + break; + case "tool.start": { + const evt = data as ToolStartEvent; + slice.streamingState = "working"; + slice.preparingTool = null; + const separatorIdx = evt.name.indexOf("__"); + const newTool: ToolCallDisplay = { + id: evt.id, + name: evt.name, + status: "running", + resourceUri: evt.resourceUri, + input: evt.input, + appName: separatorIdx !== -1 ? evt.name.slice(0, separatorIdx) : undefined, + }; + slice.toolCalls = [...slice.toolCalls, newTool]; + const last = slice.blocks[slice.blocks.length - 1]; + if (last && last.type === "tool") last.toolCalls = [...last.toolCalls, newTool]; + else slice.blocks.push({ type: "tool", toolCalls: [newTool] }); + flush(slice); + break; + } + case "tool.done": { + const evt = data as ToolDoneEvent; + const updater = updateTool(evt); + slice.toolCalls = slice.toolCalls.map(updater); + for (const block of slice.blocks) { + if (block.type === "tool") block.toolCalls = block.toolCalls.map(updater); + } + const anyRunning = slice.toolCalls.some((tc) => tc.status === "running"); + slice.streamingState = anyRunning ? "working" : "analyzing"; + flush(slice); + break; + } + case "llm.done": { + const evt = data as LlmDoneEvent; + slice.iteration = { + n: (slice.iteration?.n ?? 0) + 1, + inputTokens: (slice.iteration?.inputTokens ?? 0) + (evt.usage?.inputTokens ?? 0), + outputTokens: (slice.iteration?.outputTokens ?? 0) + (evt.usage?.outputTokens ?? 0), + }; + flush(slice); + break; + } + case "done": { + const result = data as ChatResult; + slice.streamingState = null; + slice.preparingTool = null; + slice.isStreaming = false; + + if (result.toolCalls) { + const outputMap = new Map(result.toolCalls.map((tc) => [tc.id, tc.output])); + const backfill = (tc: ToolCallDisplay): ToolCallDisplay => { + if (tc.result != null) return tc; + const output = outputMap.get(tc.id); + return output != null ? { ...tc, result: wrapStringResult(output) } : tc; + }; + for (const block of slice.blocks) { + if (block.type === "tool") block.toolCalls = block.toolCalls.map(backfill); + } + slice.toolCalls = slice.toolCalls.map(backfill); + } + + const finalBlocks = cloneBlocks(slice.blocks); + const finalTools = slice.toolCalls.length > 0 ? [...slice.toolCalls] : undefined; + const usage = result.usage + ? { + inputTokens: result.usage.inputTokens, + outputTokens: result.usage.outputTokens, + cacheReadTokens: result.usage.cacheReadTokens, + cacheWriteTokens: result.usage.cacheWriteTokens, + reasoningTokens: result.usage.reasoningTokens, + model: result.usage.model, + llmMs: result.usage.llmMs, + } + : undefined; + const resultFiles = (result as unknown as Record).files as + | MessageFileAttachment[] + | undefined; + + const updated = [...slice.messages]; + if (updated.length > 0 && updated[updated.length - 1].role === "assistant") { + updated[updated.length - 1] = { + role: "assistant", + content: result.response, + blocks: finalBlocks, + toolCalls: finalTools, + usage, + ...(result.stopReason && result.stopReason !== "complete" + ? { stopReason: result.stopReason } + : {}), + ...(resultFiles && resultFiles.length > 0 ? { files: resultFiles } : {}), + }; + slice.messages = updated; + } + resetScratch(slice); + commit(slice); + closeConnection(slice); + break; + } + case "error": { + const evt = data as StreamErrorEvent; + slice.streamingState = null; + slice.preparingTool = null; + slice.isStreaming = false; + const updated = [...slice.messages]; + const last = updated[updated.length - 1]; + if (last?.role === "assistant") { + updated[updated.length - 1] = { ...last, error: evt.message }; + slice.messages = updated; + } else { + slice.error = evt.message; + } + commit(slice); + closeConnection(slice); + break; + } + case "cancelled": { + slice.streamingState = null; + slice.preparingTool = null; + slice.isStreaming = false; + commit(slice); + closeConnection(slice); + break; + } + } + } + + // -- send (start a server turn, then watch it) -- + + async function sendTurn( + key: string, + params: StartTurnParams, + hooks?: StartTurnHooks, + ): Promise { + ensureSlice(key); + const slice = byKey.get(key); + if (!slice || slice.isStreaming) return; + + slice.error = null; + slice.isStreaming = true; + slice.streamingState = "thinking"; + slice.pendingEcho = true; + // Authoring a turn means the full conversation lives in memory. + slice.hydrated = true; + resetScratch(slice); + + // Optimistic user message + assistant placeholder for snappy UX. The + // streamed `user.message` echo is consumed (pendingEcho), not duplicated. + const userFiles: MessageFileAttachment[] | undefined = params.files?.map((f) => ({ + id: `pending_${f.name}_${f.size}`, + filename: f.name, + mimeType: f.type || "application/octet-stream", + size: f.size, + extracted: false, + })); + const userMsg: ChatMessage = { + role: "user", + content: params.text, + timestamp: new Date().toISOString(), + ...(params.currentUserId ? { userId: params.currentUserId } : {}), + ...(userFiles && userFiles.length > 0 ? { files: userFiles } : {}), + }; + const assistantMsg: ChatMessage = { + role: "assistant", + content: "", + blocks: [], + toolCalls: [], + timestamp: new Date().toISOString(), + }; + slice.messages = [...slice.messages, userMsg, assistantMsg]; + commit(slice); + + const req: ChatRequest = { + message: params.text, + ...(slice.conversationId ? { conversationId: slice.conversationId } : {}), + ...(params.appContext ? { appContext: params.appContext } : {}), + ...(params.model ? { model: params.model } : {}), + }; + + let conversationId: string; + try { + const result = + params.files && params.files.length > 0 + ? await startChatTurnMultipart(req, params.files) + : await startChatTurn(req); + conversationId = result.conversationId; + } catch (err) { + handleTurnError(slice, err); + slice.isStreaming = false; + slice.streamingState = null; + slice.pendingEcho = false; + commit(slice); + return; + } + + if (slice.conversationId !== conversationId) { + slice.conversationId = conversationId; + aliasSlice(slice, conversationId); + hooks?.onConversationId?.(conversationId); + commit(slice); + } + + // Watch the turn we just started (fresh turn — not a resume). + openConnection(slice, conversationId, false); + } + + function handleTurnError(slice: ConversationSlice, err: unknown): void { + // Drop the optimistic user+assistant placeholders on a hard start failure. + slice.messages = slice.messages.slice(0, -2); + slice.error = formatSendError(err); + } + + // -- load from disk + attach -- + + async function loadConversation(id: string): Promise { + const existing = byKey.get(id); + // Already fully loaded and live — keep the stream, don't refetch. A + // dot-only probe (connection but not hydrated) falls through so opening + // the conversation fetches its full history. + if (existing?.hydrated && (existing.isStreaming || existing.connection)) { + existing.lastActiveAt = Date.now(); + return; + } + ensureSlice(id, { conversationId: id }); + const slice = byKey.get(id); + if (slice) slice.error = null; + try { + const res = await callTool("conversations", "get", { id, expand: "full" }); + const current = byKey.get(id); + if (!current) return; + if (res.isError) { + const errText = res.content + ?.map((b) => b.text ?? "") + .filter(Boolean) + .join("\n"); + throw new Error(errText || "Failed to load conversation"); + } + let raw: unknown = res.structuredContent; + if (!raw && res.content?.[0]?.text) { + try { + raw = JSON.parse(res.content[0].text); + } catch { + raw = {}; + } + } + const parsed = raw as { + metadata: { id: string; ownerId?: string; title?: string | null }; + messages: ChatMessage[]; + }; + current.conversationId = parsed.metadata.id; + aliasSlice(current, parsed.metadata.id); + current.meta = { ownerId: parsed.metadata.ownerId }; + current.title = parsed.metadata.title ?? null; + current.messages = parsed.messages ?? []; + current.hydrated = true; + commit(current); + // Attach to any in-flight turn (resume — trims a stale in-flight turn + // from the loaded history if the server says one is active). + openConnection(current, parsed.metadata.id, true); + } catch (err) { + const slc = byKey.get(id); + if (slc) { + slc.error = err instanceof Error ? err.message : "Failed to load conversation"; + commit(slc); + } + } + } + + function cancelTurn(key: string): void { + const slice = byKey.get(key); + if (!slice?.conversationId) return; + void cancelChatTurn(slice.conversationId); + // The server emits a terminal `cancelled` event which finalizes the slice; + // no optimistic mutation needed. + } + + /** + * Lightweight "is this conversation generating?" probe — used on reload to + * restore background streaming dots without fetching message history. Opens + * a resume subscription: if the server says the turn is active, the slice + * flips to streaming (→ `getStreamingIds` → dot) and tails live; if not, the + * connection closes and the slice stays idle. Leaves `hydrated` false so a + * later open still loads full history. + */ + function probeConversation(id: string): void { + const existing = byKey.get(id); + if (existing?.isStreaming || existing?.connection) return; // already live/probed + ensureSlice(id, { conversationId: id }); + const slice = byKey.get(id); + if (slice) openConnection(slice, id, true); + } + + // -- retry / simulate -- + + function retryLastMessage(key: string): string | null { + const slice = byKey.get(key); + if (!slice) return null; + let text: string | null = null; + for (let i = slice.messages.length - 1; i >= 0; i--) { + if (slice.messages[i].role === "user") { + text = slice.messages[i].content; + slice.messages = slice.messages.slice(0, i); + break; + } + } + slice.error = null; + slice.isStreaming = false; + slice.streamingState = null; + slice.preparingTool = null; + commit(slice); + return text; + } + + function simulateError(key: string, message: string): void { + const slice = byKey.get(key); + if (!slice || slice.messages.length === 0) return; + const updated = [...slice.messages]; + const last = updated[updated.length - 1]; + if (last?.role === "assistant") { + updated[updated.length - 1] = { ...last, error: message }; + } else { + updated.push({ role: "assistant", content: "", error: message }); + } + slice.messages = updated; + slice.streamingState = null; + slice.preparingTool = null; + slice.isStreaming = false; + commit(slice); + } + + function reset(): void { + for (const slice of allSlices) slice.connection?.close(); + byKey.clear(); + allSlices.clear(); + activeCounts.clear(); + streamingIds = []; + for (const set of listeners.values()) { + for (const cb of set) cb(); + } + for (const cb of streamingListeners) cb(); + } + + return { + ensureSlice, + getSnapshot(key) { + return byKey.get(key)?.snapshot ?? EMPTY_SNAPSHOT; + }, + subscribeSlice(key, cb) { + let set = listeners.get(key); + if (!set) { + set = new Set(); + listeners.set(key, set); + } + set.add(cb); + return () => { + const s = listeners.get(key); + if (!s) return; + s.delete(cb); + if (s.size === 0) listeners.delete(key); + }; + }, + getStreamingIds() { + return streamingIds; + }, + subscribeStreamingIds(cb) { + streamingListeners.add(cb); + return () => streamingListeners.delete(cb); + }, + markActive(key) { + activeCounts.set(key, (activeCounts.get(key) ?? 0) + 1); + const slice = byKey.get(key); + if (slice) slice.lastActiveAt = Date.now(); + }, + markInactive(key) { + const n = (activeCounts.get(key) ?? 0) - 1; + if (n <= 0) activeCounts.delete(key); + else activeCounts.set(key, n); + }, + sendTurn, + loadConversation, + probeConversation, + setTitle(conversationId, title) { + const slice = byKey.get(conversationId); + if (!slice || slice.title === title) return; + slice.title = title; + commit(slice); + }, + cancelTurn, + retryLastMessage, + simulateError, + reset, + sliceCount() { + return allSlices.size; + }, + }; +} + +/** Module-singleton store. */ +export const chatStore = createChatStore(); diff --git a/web/src/hooks/useChat.ts b/web/src/hooks/useChat.ts index ce1a1024..53a7d918 100644 --- a/web/src/hooks/useChat.ts +++ b/web/src/hooks/useChat.ts @@ -1,136 +1,27 @@ -import { useCallback, useEffect, useRef, useState } from "react"; -import { ApiClientError, callTool, streamChat, streamChatMultipart } from "../api/client"; -import { formatSendError } from "../api/format-error"; +import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react"; import { captureEvent } from "../telemetry"; +import type { AppContext } from "../types"; import type { - AppContext, - ChatRequest, - ChatResult, - ChatStreamEventMap, - ChatStreamEventType, - LlmDoneEvent, - ReasoningDeltaEvent, - StreamErrorEvent, - TextDeltaEvent, - ToolDoneEvent, - ToolPreparingEvent, - ToolStartEvent, -} from "../types"; - -/** - * Streaming state machine: - * - * null → thinking → streaming ↔ preparing → working → analyzing → streaming → null - * ↘ working (next tool.start) - * - * `analyzing` fills the gap between the last tool.done (all tools finished) - * and the next text.delta / tool.start, when the model is inferring on tool - * results but the UI would otherwise look frozen. - * - * `preparing` fills the model-side gap: after text/reasoning has streamed - * within an iteration, the model may continue emitting a large tool-call - * input block (45 KB+ for full-document writes). No deltas fire during - * that window — without `preparing`, the indicator goes dark for as long - * as it takes the LLM to emit the args. `tool.preparing` fires on - * `tool-input-start` from the AI SDK; `tool.start` follows once the - * iteration finishes and the engine begins execution. - * - * Any `tool.start` can re-enter `working` from a non-terminal state. - */ -export type StreamingState = - | null - | "thinking" - | "streaming" - | "preparing" - | "working" - | "analyzing"; - -/** Identifies the tool the model is currently building a call for. */ -export interface PreparingTool { - id: string; - name: string; -} - -/** Typed tool result shape forwarded through the bridge. */ -export interface ToolResultForUI { - content: Array<{ type: string; text?: string; [key: string]: unknown }>; - structuredContent?: Record; - isError: boolean; -} - -/** Tool call with UI state for streaming display. */ -export interface ToolCallDisplay { - id: string; - name: string; - status: "running" | "done" | "error"; - ok?: boolean; - ms?: number; - resourceUri?: string; - /** MCP `resource_link` blocks returned by the tool result, if any. */ - resourceLinks?: Array<{ - uri: string; - name?: string; - mimeType?: string; - description?: string; - }>; - result?: ToolResultForUI; - input?: Record; - appName?: string; -} - -/** A block in the assistant message stream — text, reasoning, or tool call group, in temporal order. */ -export type ContentBlock = - | { type: "text"; text: string } - | { type: "reasoning"; text: string } - | { type: "tool"; toolCalls: ToolCallDisplay[] }; - -/** Live iteration progress during streaming. */ -export interface IterationProgress { - n: number; - inputTokens: number; - outputTokens: number; -} - -/** File metadata attached to a message. */ -export interface MessageFileAttachment { - id: string; - filename: string; - mimeType: string; - size: number; - extracted: boolean; -} - -/** A chat message with ordered content blocks for display. */ -export interface ChatMessage { - role: "user" | "assistant"; - content: string; - blocks?: ContentBlock[]; - toolCalls?: ToolCallDisplay[]; - iteration?: IterationProgress; - timestamp?: string; - userId?: string; - files?: MessageFileAttachment[]; - stopReason?: string; - /** Set when the engine errors mid-stream — renders inline on the message. */ - error?: string; - usage?: { - inputTokens: number; - outputTokens: number; - cacheReadTokens?: number; - cacheWriteTokens?: number; - reasoningTokens?: number; - model: string; - llmMs: number; - }; -} - -/** - * Conversation-level metadata. Stage 1: single-owner only — sharing - * was removed (returns in Stage 4 with policy gating). - */ -export interface LoadedConversationMeta { - ownerId?: string; -} + ChatMessage, + LoadedConversationMeta, + PreparingTool, + StreamingState, +} from "./chat-store"; +import { chatStore, freshDraftKey } from "./chat-store"; + +// Re-export the display types so existing `from "../hooks/useChat"` imports +// keep working — the slice store now owns the definitions. +export type { + ChatMessage, + ContentBlock, + IterationProgress, + LoadedConversationMeta, + MessageFileAttachment, + PreparingTool, + StreamingState, + ToolCallDisplay, + ToolResultForUI, +} from "./chat-store"; export interface UseChatReturn { messages: ChatMessage[]; @@ -139,6 +30,8 @@ export interface UseChatReturn { /** Set while streamingState === "preparing"; null otherwise. */ preparingTool: PreparingTool | null; conversationId: string | null; + /** Server-generated title; null until generated/loaded. */ + title: string | null; conversationMeta: LoadedConversationMeta | null; error: string | null; sendMessage: ( @@ -149,687 +42,117 @@ export interface UseChatReturn { ) => Promise; newConversation: () => void; loadConversation: (id: string) => Promise; - /** Inject a user message from another participant (remote stream). */ - injectRemoteUserMessage: (userId: string, displayName: string, content: string) => void; - /** Process a streaming event from a remote participant's assistant response. */ - processRemoteStreamEvent: (type: string, data: unknown) => void; + /** Stop the in-flight turn (the only thing that aborts generation). */ + stop: () => void; /** Retry the last failed message (removes errored pair and re-sends). */ retryLastMessage: () => void; /** Inject a synthetic error for demoing the error UX (dev only). */ simulateError: (message: string) => void; } -/** Deep-copy blocks for immutable state updates. */ -function cloneBlocks(blocks: ContentBlock[]): ContentBlock[] { - return blocks.map((b) => { - if (b.type === "tool") return { ...b, toolCalls: [...b.toolCalls] }; - return { ...b }; // text or reasoning — both shaped { type, text } +/** + * Per-conversation chat state, backed by the module-singleton {@link chatStore}. + * + * `activeKey` selects which conversation's slice this hook renders. A stream + * started for one conversation writes only into that conversation's slice + * (captured at send time), so switching conversations mid-turn never bleeds + * the in-flight response into the destination chat (issue #254). Switching + * back shows the still-arriving response because the background stream kept + * filling its origin slice. + */ +export function useChat(initialConversationId?: string, currentUserId?: string): UseChatReturn { + const [activeKey, setActiveKey] = useState(() => { + const key = initialConversationId ?? freshDraftKey(); + chatStore.ensureSlice( + key, + initialConversationId ? { conversationId: initialConversationId } : undefined, + ); + return key; }); -} - -/** Derive full visible text from blocks. Reasoning is NOT included - * (it's collapsed-by-default UI and shouldn't pollute the message body). */ -function textFromBlocks(blocks: ContentBlock[]): string { - return blocks - .filter((b): b is ContentBlock & { type: "text" } => b.type === "text") - .map((b) => b.text) - .join(""); -} - -/** Wrap a plain string result into a ToolResultForUI. */ -function wrapStringResult(text: string, isError = false): ToolResultForUI { - return { content: [{ type: "text", text }], isError }; -} -const updateTool = - (evt: ToolDoneEvent) => - (tc: ToolCallDisplay): ToolCallDisplay => - tc.id === evt.id - ? { - ...tc, - status: evt.ok ? ("done" as const) : ("error" as const), - ok: evt.ok, - ms: evt.ms, - resourceUri: tc.resourceUri ?? evt.resourceUri, - resourceLinks: - evt.resourceLinks != null && evt.resourceLinks.length > 0 - ? evt.resourceLinks - : tc.resourceLinks, - result: evt.result != null ? (evt.result as ToolResultForUI) : tc.result, - } - : tc; - -export function useChat(initialConversationId?: string, currentUserId?: string): UseChatReturn { - const [messages, setMessages] = useState([]); - const [isStreaming, setIsStreaming] = useState(false); - const [conversationId, setConversationId] = useState( - initialConversationId ?? null, + const subscribe = useCallback( + (cb: () => void) => chatStore.subscribeSlice(activeKey, cb), + [activeKey], ); - const [error, setError] = useState(null); - const [streamingState, setStreamingState] = useState(null); - const [preparingTool, setPreparingTool] = useState(null); - const [conversationMeta, setConversationMeta] = useState(null); - - // Refs for building the current assistant message during streaming. - const blocksRef = useRef([]); - const toolCallsRef = useRef([]); - const iterationRef = useRef(undefined); + const getSnapshot = useCallback(() => chatStore.getSnapshot(activeKey), [activeKey]); + const snap = useSyncExternalStore(subscribe, getSnapshot); - /** Push current refs into the last assistant message. */ - function flushToMessage() { - const currentBlocks = cloneBlocks(blocksRef.current); - const currentText = textFromBlocks(blocksRef.current); - const currentTools = [...toolCallsRef.current]; - const currentIteration = iterationRef.current ? { ...iterationRef.current } : undefined; - setMessages((prev) => { - const updated = [...prev]; - updated[updated.length - 1] = { - role: "assistant", - content: currentText, - blocks: currentBlocks, - toolCalls: currentTools, - iteration: currentIteration, - }; - return updated; - }); - } + // Mark the active slice so the LRU never evicts what the user is viewing. + useEffect(() => { + chatStore.markActive(activeKey); + return () => chatStore.markInactive(activeKey); + }, [activeKey]); const sendMessage = useCallback( async (text: string, appContext?: AppContext, model?: string, files?: File[]) => { - if (isStreaming) return; - - setError(null); - setIsStreaming(true); - setStreamingState("thinking"); - blocksRef.current = []; - toolCallsRef.current = []; - iterationRef.current = undefined; - - // Add user message (with file previews if attached) - const userFiles: MessageFileAttachment[] | undefined = files?.map((f) => ({ - id: `pending_${f.name}_${f.size}`, - filename: f.name, - mimeType: f.type || "application/octet-stream", - size: f.size, - extracted: false, - })); - const userMsg: ChatMessage = { - role: "user", - content: text, - timestamp: new Date().toISOString(), - ...(currentUserId ? { userId: currentUserId } : {}), - ...(userFiles && userFiles.length > 0 ? { files: userFiles } : {}), - }; - setMessages((prev) => [...prev, userMsg]); - - // Add placeholder assistant message - const assistantMsg: ChatMessage = { - role: "assistant", - content: "", - blocks: [], - toolCalls: [], - timestamp: new Date().toISOString(), - }; - setMessages((prev) => [...prev, assistantMsg]); - - // Enrich appContext with latest app state from the bridge (Synapse Feature 2) + // Enrich appContext with the latest app state from the bridge + // (Synapse Feature 2). Kept here — the store stays bridge-agnostic. let enrichedContext = appContext; if (appContext) { const { getAppState } = await import("../bridge/bridge"); const appStateEntry = getAppState(appContext.serverName); - if (appStateEntry) { - enrichedContext = { ...appContext, appState: appStateEntry }; - } - } - - const req: ChatRequest = { - message: text, - ...(conversationId ? { conversationId } : {}), - ...(enrichedContext ? { appContext: enrichedContext } : {}), - ...(model ? { model } : {}), - }; - - try { - const onEvent = (type: K, data: ChatStreamEventMap[K]) => { - switch (type) { - case "chat.start": { - const evt = data as { conversationId: string }; - if (evt.conversationId) { - setConversationId(evt.conversationId); - } - break; - } - case "text.delta": { - const evt = data as TextDeltaEvent; - setStreamingState((prev) => (prev !== "streaming" ? "streaming" : prev)); - // Defensive: keeps `preparingTool` paired with the - // `"preparing"` streamingState. Render sites gate on the - // state, so stale data never shows today, but a future - // caller reading `preparingTool` directly would otherwise - // see a tool name from a long-finished iteration. - setPreparingTool(null); - // Append to last text block or create a new one - const blocks = blocksRef.current; - const lastBlock = blocks[blocks.length - 1]; - if (lastBlock && lastBlock.type === "text") { - lastBlock.text += evt.text; - } else { - blocks.push({ type: "text", text: evt.text }); - } - flushToMessage(); - break; - } - case "reasoning.delta": { - const evt = data as ReasoningDeltaEvent; - setStreamingState((prev) => (prev !== "streaming" ? "streaming" : prev)); - setPreparingTool(null); - const blocks = blocksRef.current; - const lastBlock = blocks[blocks.length - 1]; - if (lastBlock && lastBlock.type === "reasoning") { - lastBlock.text += evt.text; - } else { - blocks.push({ type: "reasoning", text: evt.text }); - } - flushToMessage(); - break; - } - case "tool.preparing": { - const evt = data as ToolPreparingEvent; - setStreamingState("preparing"); - setPreparingTool({ id: evt.id, name: evt.name }); - break; - } - case "tool.preparing.done": { - // No state change — `tool.start` follows once the iteration - // ends and the engine begins execution. Holding `preparing` - // through the gap keeps the indicator stable. - break; - } - case "tool.start": { - const evt = data as ToolStartEvent; - setStreamingState("working"); - setPreparingTool(null); - const separatorIdx = evt.name.indexOf("__"); - const newTool: ToolCallDisplay = { - id: evt.id, - name: evt.name, - status: "running", - resourceUri: evt.resourceUri, - input: evt.input, - appName: separatorIdx !== -1 ? evt.name.slice(0, separatorIdx) : undefined, - }; - // Flat ref - toolCallsRef.current = [...toolCallsRef.current, newTool]; - // Blocks — group consecutive tool calls - const blocks = blocksRef.current; - const lastBlock = blocks[blocks.length - 1]; - if (lastBlock && lastBlock.type === "tool") { - lastBlock.toolCalls = [...lastBlock.toolCalls, newTool]; - } else { - blocks.push({ type: "tool", toolCalls: [newTool] }); - } - flushToMessage(); - break; - } - case "tool.done": { - const evt = data as ToolDoneEvent; - const updater = updateTool(evt); - // Update flat ref - toolCallsRef.current = toolCallsRef.current.map(updater); - // Update in blocks - for (const block of blocksRef.current) { - if (block.type === "tool") { - block.toolCalls = block.toolCalls.map(updater); - } - } - // Hold `working` while other parallel tools are still running; - // only flip to `analyzing` when the last tool in the batch lands, - // so the indicator reflects "model is inferring on results." - const anyRunning = toolCallsRef.current.some((tc) => tc.status === "running"); - setStreamingState(anyRunning ? "working" : "analyzing"); - flushToMessage(); - break; - } - case "llm.done": { - const evt = data as LlmDoneEvent; - iterationRef.current = { - n: (iterationRef.current?.n ?? 0) + 1, - inputTokens: - (iterationRef.current?.inputTokens ?? 0) + (evt.usage?.inputTokens ?? 0), - outputTokens: - (iterationRef.current?.outputTokens ?? 0) + (evt.usage?.outputTokens ?? 0), - }; - flushToMessage(); - break; - } - case "done": { - const result = data as ChatResult; - setStreamingState(null); - setPreparingTool(null); - setConversationId(result.conversationId); - - // Backfill tool results from done event - if (result.toolCalls) { - const outputMap = new Map(result.toolCalls.map((tc) => [tc.id, tc.output])); - const backfill = (tc: ToolCallDisplay): ToolCallDisplay => { - if (tc.result != null) return tc; - const output = outputMap.get(tc.id); - return output != null ? { ...tc, result: wrapStringResult(output) } : tc; - }; - for (const block of blocksRef.current) { - if (block.type === "tool") { - block.toolCalls = block.toolCalls.map(backfill); - } - } - toolCallsRef.current = toolCallsRef.current.map(backfill); - } - - const finalBlocks = cloneBlocks(blocksRef.current); - const finalTools = - toolCallsRef.current.length > 0 ? [...toolCallsRef.current] : undefined; - const usage = result.usage - ? { - inputTokens: result.usage.inputTokens, - outputTokens: result.usage.outputTokens, - cacheReadTokens: result.usage.cacheReadTokens, - cacheWriteTokens: result.usage.cacheWriteTokens, - reasoningTokens: result.usage.reasoningTokens, - model: result.usage.model, - llmMs: result.usage.llmMs, - } - : undefined; - // Parse file attachments from done event metadata - const resultFiles = (result as unknown as Record).files as - | MessageFileAttachment[] - | undefined; - - setMessages((prev) => { - const updated = [...prev]; - updated[updated.length - 1] = { - role: "assistant", - content: result.response, - blocks: finalBlocks, - toolCalls: finalTools, - usage, - ...(result.stopReason && result.stopReason !== "complete" - ? { stopReason: result.stopReason } - : {}), - ...(resultFiles && resultFiles.length > 0 ? { files: resultFiles } : {}), - }; - return updated; - }); - break; - } - case "error": { - const evt = data as StreamErrorEvent; - setStreamingState(null); - setPreparingTool(null); - // Stamp the error on the last assistant message so it renders - // inline — not as a disconnected banner at the top. - setMessages((prev) => { - const updated = [...prev]; - const last = updated[updated.length - 1]; - if (last?.role === "assistant") { - updated[updated.length - 1] = { ...last, error: evt.message }; - } else { - // No assistant message to attach to — fall back to banner - setError(evt.message); - } - return updated; - }); - break; - } - } - }; - if (files && files.length > 0) { - await streamChatMultipart(req, files, onEvent); - } else { - await streamChat(req, onEvent); - } - captureEvent("web.chat_sent", { - is_resume: !!conversationId, - has_app_context: !!appContext, - }); - } catch (err) { - if (err instanceof ApiClientError && err.code === "run_in_progress") { - // Server rejected because a previous run is still in flight. - // Drop the optimistic user+assistant placeholders so the failed - // message doesn't stick in history as if it had succeeded. - setMessages((prev) => prev.slice(0, -2)); - captureEvent("web.chat_run_in_progress", { - conversation_id: conversationId ?? null, - has_app_context: !!appContext, - }); - // Banner only — nothing in this turn to mark inline - setError(formatSendError(err)); - return; - } - const msg = formatSendError(err); - // Stamp on the last assistant message if one exists; - // only fall back to banner when there's no message to attach to. - setMessages((prev) => { - const last = prev[prev.length - 1]; - if (last?.role === "assistant") { - const updated = [...prev]; - updated[updated.length - 1] = { ...last, error: msg }; - return updated; - } - // No assistant message — fall back to banner - setError(msg); - return prev; - }); - } finally { - setIsStreaming(false); - setStreamingState(null); - setPreparingTool(null); + if (appStateEntry) enrichedContext = { ...appContext, appState: appStateEntry }; } + const hadConversation = !!chatStore.getSnapshot(activeKey).conversationId; + await chatStore.sendTurn( + activeKey, + { text, appContext: enrichedContext, model, files, currentUserId }, + { onConversationId: (id) => setActiveKey(id) }, + ); + captureEvent("web.chat_sent", { + is_resume: hadConversation, + has_app_context: !!appContext, + }); }, - // biome-ignore lint/correctness/useExhaustiveDependencies: sendMessage captures streaming/conversation state via refs - [isStreaming, conversationId, currentUserId, flushToMessage], + [activeKey, currentUserId], ); const newConversation = useCallback(() => { - setMessages([]); - setConversationId(null); - setConversationMeta(null); - setError(null); - setIsStreaming(false); - setStreamingState(null); - setPreparingTool(null); - blocksRef.current = []; - toolCallsRef.current = []; - iterationRef.current = undefined; + const key = freshDraftKey(); + chatStore.ensureSlice(key); + setActiveKey(key); }, []); const loadConversation = useCallback(async (id: string) => { - setError(null); - try { - // expand:"full" — the shell is rendering the entire chat, not - // sampling for an LLM. The bounded default (`expand:"messages"`, - // last 20) exists to keep agent tool-results small; the trusted - // web shell needs every turn or the UI silently shows only the tail. - const res = await callTool("conversations", "get", { id, expand: "full" }); - if (res.isError) { - const errText = res.content - ?.map((b) => b.text ?? "") - .filter(Boolean) - .join("\n"); - throw new Error(errText || "Failed to load conversation"); - } - // Prefer structuredContent; fall back to parsing first text block. - let raw: unknown = res.structuredContent; - if (!raw && res.content?.[0]?.text) { - try { - raw = JSON.parse(res.content[0].text); - } catch { - raw = {}; - } - } - // The API already returns DisplayMessage[] in the exact shape ChatMessage - // expects — one message per turn, blocks in iteration order, tool calls - // hydrated with status+result. No reshaping needed here. - const data = raw as { - metadata: { - id: string; - ownerId?: string; - }; - messages: ChatMessage[]; - }; - setConversationId(data.metadata.id); - setConversationMeta({ ownerId: data.metadata.ownerId }); - setMessages(data.messages); - } catch (err) { - const msg = err instanceof Error ? err.message : "Failed to load conversation"; - setError(msg); - } + setActiveKey(id); + await chatStore.loadConversation(id); }, []); - // --- Remote participant event injection --- + const stop = useCallback(() => { + chatStore.cancelTurn(activeKey); + }, [activeKey]); - const injectRemoteUserMessage = useCallback( - (userId: string, _displayName: string, content: string) => { - // Reset streaming refs for the incoming remote assistant response - blocksRef.current = []; - toolCallsRef.current = []; - iterationRef.current = undefined; + const retryLastMessage = useCallback(() => { + const text = chatStore.retryLastMessage(activeKey); + if (text != null) void sendMessage(text); + }, [activeKey, sendMessage]); - const userMsg: ChatMessage = { - role: "user", - content, - timestamp: new Date().toISOString(), - userId, - }; - const assistantMsg: ChatMessage = { - role: "assistant", - content: "", - blocks: [], - toolCalls: [], - timestamp: new Date().toISOString(), - }; - setMessages((prev) => [...prev, userMsg, assistantMsg]); - setIsStreaming(true); - setStreamingState("thinking"); + const simulateError = useCallback( + (message: string) => { + chatStore.simulateError(activeKey, message); }, - [], + [activeKey], ); - const processRemoteStreamEvent = useCallback( - (type: string, data: unknown) => { - switch (type) { - case "text.delta": { - const evt = data as TextDeltaEvent; - setStreamingState((prev) => (prev !== "streaming" ? "streaming" : prev)); - setPreparingTool(null); - const blocks = blocksRef.current; - const lastBlock = blocks[blocks.length - 1]; - if (lastBlock && lastBlock.type === "text") { - lastBlock.text += evt.text; - } else { - blocks.push({ type: "text", text: evt.text }); - } - flushToMessage(); - break; - } - case "reasoning.delta": { - const evt = data as ReasoningDeltaEvent; - setStreamingState((prev) => (prev !== "streaming" ? "streaming" : prev)); - setPreparingTool(null); - const blocks = blocksRef.current; - const lastBlock = blocks[blocks.length - 1]; - if (lastBlock && lastBlock.type === "reasoning") { - lastBlock.text += evt.text; - } else { - blocks.push({ type: "reasoning", text: evt.text }); - } - flushToMessage(); - break; - } - case "tool.preparing": { - const evt = data as ToolPreparingEvent; - setStreamingState("preparing"); - setPreparingTool({ id: evt.id, name: evt.name }); - break; - } - case "tool.preparing.done": { - break; - } - case "tool.start": { - const evt = data as ToolStartEvent; - setStreamingState("working"); - setPreparingTool(null); - const separatorIdx = evt.name.indexOf("__"); - const newTool: ToolCallDisplay = { - id: evt.id, - name: evt.name, - status: "running", - resourceUri: evt.resourceUri, - input: evt.input, - appName: separatorIdx !== -1 ? evt.name.slice(0, separatorIdx) : undefined, - }; - toolCallsRef.current = [...toolCallsRef.current, newTool]; - const blocks = blocksRef.current; - const lastBlock = blocks[blocks.length - 1]; - if (lastBlock && lastBlock.type === "tool") { - lastBlock.toolCalls = [...lastBlock.toolCalls, newTool]; - } else { - blocks.push({ type: "tool", toolCalls: [newTool] }); - } - flushToMessage(); - break; - } - case "tool.done": { - const evt = data as ToolDoneEvent; - const updater = updateTool(evt); - toolCallsRef.current = toolCallsRef.current.map(updater); - for (const block of blocksRef.current) { - if (block.type === "tool") { - block.toolCalls = block.toolCalls.map(updater); - } - } - const anyRunning = toolCallsRef.current.some((tc) => tc.status === "running"); - setStreamingState(anyRunning ? "working" : "analyzing"); - flushToMessage(); - break; - } - case "llm.done": { - const evt = data as LlmDoneEvent; - iterationRef.current = { - n: (iterationRef.current?.n ?? 0) + 1, - inputTokens: (iterationRef.current?.inputTokens ?? 0) + (evt.usage?.inputTokens ?? 0), - outputTokens: - (iterationRef.current?.outputTokens ?? 0) + (evt.usage?.outputTokens ?? 0), - }; - flushToMessage(); - break; - } - case "done": { - const result = data as ChatResult; - setStreamingState(null); - setPreparingTool(null); - setIsStreaming(false); - - if (result.toolCalls) { - const outputMap = new Map(result.toolCalls.map((tc) => [tc.id, tc.output])); - const backfill = (tc: ToolCallDisplay): ToolCallDisplay => { - if (tc.result != null) return tc; - const output = outputMap.get(tc.id); - return output != null ? { ...tc, result: wrapStringResult(output) } : tc; - }; - for (const block of blocksRef.current) { - if (block.type === "tool") { - block.toolCalls = block.toolCalls.map(backfill); - } - } - toolCallsRef.current = toolCallsRef.current.map(backfill); - } - - const finalBlocks = cloneBlocks(blocksRef.current); - const finalTools = - toolCallsRef.current.length > 0 ? [...toolCallsRef.current] : undefined; - const usage = result.usage - ? { - inputTokens: result.usage.inputTokens, - outputTokens: result.usage.outputTokens, - cacheReadTokens: result.usage.cacheReadTokens, - cacheWriteTokens: result.usage.cacheWriteTokens, - reasoningTokens: result.usage.reasoningTokens, - model: result.usage.model, - llmMs: result.usage.llmMs, - } - : undefined; - - setMessages((prev) => { - const updated = [...prev]; - updated[updated.length - 1] = { - role: "assistant", - content: result.response, - blocks: finalBlocks, - toolCalls: finalTools, - usage, - ...(result.stopReason && result.stopReason !== "complete" - ? { stopReason: result.stopReason } - : {}), - }; - return updated; - }); - break; - } - } - // biome-ignore lint/correctness/useExhaustiveDependencies: SSE handler intentionally captures only flushToMessage - }, - [flushToMessage], + return useMemo( + () => ({ + messages: snap.messages, + isStreaming: snap.isStreaming, + streamingState: snap.streamingState, + preparingTool: snap.preparingTool, + // Drafts carry a null conversationId on the slice, so this is null until + // the server assigns a real id on chat.start. + conversationId: snap.conversationId, + title: snap.title, + conversationMeta: snap.meta, + error: snap.error, + sendMessage, + newConversation, + loadConversation, + stop, + retryLastMessage, + simulateError, + }), + [snap, sendMessage, newConversation, loadConversation, stop, retryLastMessage, simulateError], ); - - // Pending retry text — set by retryLastMessage, consumed by an effect - const retryRef = useRef(null); - - const retryLastMessage = useCallback(() => { - // Find the last user message, stash its text, remove the failed pair - setMessages((prev) => { - for (let i = prev.length - 1; i >= 0; i--) { - if (prev[i].role === "user") { - retryRef.current = prev[i].content; - return prev.slice(0, i); - } - } - return prev; - }); - // Clear error + streaming state so sendMessage's guard passes - setError(null); - setIsStreaming(false); - setStreamingState(null); - setPreparingTool(null); - }, []); - - // Effect: once isStreaming is false and there's a pending retry, fire it. - // We can't call sendMessage synchronously from retryLastMessage because - // sendMessage is memoized with isStreaming in its dep list — the closure - // still sees isStreaming=true until React re-renders with the new state. - // This effect fires after React flushes the state updates, at which point - // sendMessage has been recreated with isStreaming=false. - // NOTE: this depends on sendMessage's identity changing when isStreaming - // changes (via flushToMessage in its dep list). Do not memoize - // flushToMessage without verifying this still fires. - useEffect(() => { - if (!isStreaming && retryRef.current) { - const text = retryRef.current; - retryRef.current = null; - sendMessage(text); - } - }, [isStreaming, sendMessage]); - - const simulateError = useCallback((message: string) => { - setMessages((prev) => { - if (prev.length === 0) return prev; - const updated = [...prev]; - const last = updated[updated.length - 1]; - if (last?.role === "assistant") { - updated[updated.length - 1] = { ...last, error: message }; - } else { - // Append a synthetic assistant message with the error - updated.push({ role: "assistant", content: "", error: message }); - } - return updated; - }); - setStreamingState(null); - setPreparingTool(null); - setIsStreaming(false); - }, []); - - return { - messages, - isStreaming, - streamingState, - preparingTool, - conversationId, - conversationMeta, - error, - sendMessage, - newConversation, - loadConversation, - injectRemoteUserMessage, - processRemoteStreamEvent, - retryLastMessage, - simulateError, - }; } diff --git a/web/src/hooks/useEvents.ts b/web/src/hooks/useEvents.ts index 2920c586..b7c7cc4b 100644 --- a/web/src/hooks/useEvents.ts +++ b/web/src/hooks/useEvents.ts @@ -4,6 +4,7 @@ import { connectEvents } from "../api/sse"; import type { ConfigChangedEvent, ConnectionStateChangedEvent, + ConversationTitleEvent, DataChangedEvent, SseEventMap, SseEventType, @@ -14,6 +15,8 @@ export interface UseEventsOptions { onDataChanged?: (event: DataChangedEvent) => void; /** Called when a config.changed SSE event is received. */ onConfigChanged?: (event: ConfigChangedEvent) => void; + /** Called when an auto-generated conversation title arrives. */ + onConversationTitle?: (event: ConversationTitleEvent) => void; /** Called when a per-Connection state transition fires (URL bundles). */ onConnectionStateChanged?: (event: ConnectionStateChangedEvent) => void; /** @@ -41,6 +44,8 @@ export function useEvents( onDataChangedRef.current = options?.onDataChanged; const onConfigChangedRef = useRef(options?.onConfigChanged); onConfigChangedRef.current = options?.onConfigChanged; + const onConversationTitleRef = useRef(options?.onConversationTitle); + onConversationTitleRef.current = options?.onConversationTitle; const onConnectionStateChangedRef = useRef(options?.onConnectionStateChanged); onConnectionStateChangedRef.current = options?.onConnectionStateChanged; const onBundleLifecycleChangedRef = useRef(options?.onBundleLifecycleChanged); @@ -59,6 +64,9 @@ export function useEvents( if (type === "config.changed") { onConfigChangedRef.current?.(data as ConfigChangedEvent); } + if (type === "conversation.title") { + onConversationTitleRef.current?.(data as ConversationTitleEvent); + } if (type === "connection.state_changed") { onConnectionStateChangedRef.current?.(data as ConnectionStateChangedEvent); } diff --git a/web/src/lib/active-conversation-storage.ts b/web/src/lib/active-conversation-storage.ts new file mode 100644 index 00000000..cf5c9d7d --- /dev/null +++ b/web/src/lib/active-conversation-storage.ts @@ -0,0 +1,60 @@ +/** + * Per-tab persistence of the last-viewed conversation id. + * + * Stored in `sessionStorage` so it is: + * - site-scoped (per-origin) and never sent to the server, + * - per-tab (two tabs don't clobber each other's active conversation), + * - cleared automatically when the tab closes (no stale-id cleanup). + * + * On a fresh page load the chat panel reads this and re-opens the + * conversation, which re-subscribes to the server turn stream — so an + * in-flight turn's streaming indicator resumes (the actual stream lives + * server-side; only the id needs remembering). The `/chat/:id` route + * restores from the URL instead and doesn't use this. + */ + +const KEY = "nb:activeConversationId"; +const STREAMING_KEY = "nb:streamingConversationIds"; + +export function getSavedConversationId(): string | null { + try { + return sessionStorage.getItem(KEY); + } catch { + // sessionStorage can throw in private-mode / sandboxed contexts. + return null; + } +} + +export function setSavedConversationId(id: string | null): void { + try { + if (id) sessionStorage.setItem(KEY, id); + else sessionStorage.removeItem(KEY); + } catch { + // Best-effort — persistence is an enhancement, not a correctness path. + } +} + +/** + * Conversation ids that had an in-flight turn when the page was last alive. + * On reload these are re-probed against the server (`isActive`) to restore the + * list's streaming dots; finished ones self-heal (probe → not active → no dot). + */ +export function getSavedStreamingIds(): string[] { + try { + const raw = sessionStorage.getItem(STREAMING_KEY); + if (!raw) return []; + const parsed: unknown = JSON.parse(raw); + return Array.isArray(parsed) ? parsed.filter((x): x is string => typeof x === "string") : []; + } catch { + return []; + } +} + +export function setSavedStreamingIds(ids: string[]): void { + try { + if (ids.length > 0) sessionStorage.setItem(STREAMING_KEY, JSON.stringify(ids)); + else sessionStorage.removeItem(STREAMING_KEY); + } catch { + // Best-effort. + } +} diff --git a/web/src/types.ts b/web/src/types.ts index c3f014ab..f17be2b4 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -163,6 +163,13 @@ export interface ConfigChangedEvent { timestamp: string; } +/** Live auto-generated conversation title (routed to a slice by conversationId). */ +export interface ConversationTitleEvent { + conversationId: string; + title: string; + wsId?: string; +} + /** SSE event type to payload mapping. */ export interface SseEventMap { "bundle.installed": BundleInstalledEvent; @@ -172,6 +179,7 @@ export interface SseEventMap { "bundle.dead": BundleDeadEvent; "connection.state_changed": ConnectionStateChangedEvent; "data.changed": DataChangedEvent; + "conversation.title": ConversationTitleEvent; "config.changed": ConfigChangedEvent; heartbeat: HeartbeatEvent; } diff --git a/web/test/active-conversation-storage.test.ts b/web/test/active-conversation-storage.test.ts new file mode 100644 index 00000000..ff30a483 --- /dev/null +++ b/web/test/active-conversation-storage.test.ts @@ -0,0 +1,26 @@ +import { beforeEach, describe, expect, it } from "bun:test"; +import { + getSavedConversationId, + setSavedConversationId, +} from "../src/lib/active-conversation-storage"; + +describe("active-conversation-storage", () => { + beforeEach(() => { + sessionStorage.clear(); + }); + + it("returns null when nothing is saved", () => { + expect(getSavedConversationId()).toBeNull(); + }); + + it("round-trips a conversation id", () => { + setSavedConversationId("conv_abc123"); + expect(getSavedConversationId()).toBe("conv_abc123"); + }); + + it("clears the saved id when set to null (new/draft chat)", () => { + setSavedConversationId("conv_abc123"); + setSavedConversationId(null); + expect(getSavedConversationId()).toBeNull(); + }); +}); diff --git a/web/test/chat-store.test.ts b/web/test/chat-store.test.ts new file mode 100644 index 00000000..7f7d455c --- /dev/null +++ b/web/test/chat-store.test.ts @@ -0,0 +1,289 @@ +import { beforeEach, describe, expect, it, mock } from "bun:test"; +import type { ChatMessage } from "../src/hooks/chat-store.ts"; + +// --------------------------------------------------------------------------- +// Drive the chat store directly (no React). The store is now a *viewer* over +// the server turn stream: sendTurn → startChatTurn (POST) → subscribe via +// connectConversationStream. We mock both seams and drive the captured +// stream callback to simulate server events. +// +// Bun module mocks are process-global; we spread the real client and override +// only the turn transport so sibling suites keep the real exports. +// --------------------------------------------------------------------------- + +interface CapturedStream { + conversationId: string; + onEvent: (type: string, data: unknown, seq: number) => void; + onSubscribed?: (info: { isActive: boolean; activeSeq: number }) => void; + closed: boolean; +} +let streams: CapturedStream[] = []; +let convCounter = 0; + +const LOADED: ChatMessage[] = [ + { role: "user", content: "loaded-q" }, + { role: "assistant", content: "loaded-a", blocks: [{ type: "text", text: "loaded-a" }] }, +]; + +mock.module("../src/api/conversation-stream", () => ({ + connectConversationStream: (opts: { + conversationId: string; + onEvent: (type: string, data: unknown, seq: number) => void; + onSubscribed?: (info: { isActive: boolean; activeSeq: number }) => void; + }) => { + const entry: CapturedStream = { + conversationId: opts.conversationId, + onEvent: opts.onEvent, + onSubscribed: opts.onSubscribed, + closed: false, + }; + streams.push(entry); + return { + close() { + entry.closed = true; + }, + }; + }, +})); + +const actualClient = await import("../src/api/client"); +mock.module("../src/api/client", () => ({ + ...actualClient, + startChatTurn: (req: { conversationId?: string }) => { + convCounter += 1; + return Promise.resolve({ conversationId: req.conversationId ?? `conv_${convCounter}` }); + }, + startChatTurnMultipart: (req: { conversationId?: string }) => { + convCounter += 1; + return Promise.resolve({ conversationId: req.conversationId ?? `conv_${convCounter}` }); + }, + cancelChatTurn: () => Promise.resolve(), + callTool: (_server: string, _action: string, args?: Record) => + Promise.resolve({ + isError: false, + structuredContent: { metadata: { id: args?.id }, messages: LOADED }, + }), +})); + +import { createChatStore, freshDraftKey } from "../src/hooks/chat-store.ts"; + +function lastAssistant(messages: ChatMessage[]): ChatMessage | undefined { + for (let i = messages.length - 1; i >= 0; i--) { + if (messages[i].role === "assistant") return messages[i]; + } + return undefined; +} + +/** The most-recently-opened stream (the one a just-sent turn subscribed to). */ +function latestStream(): CapturedStream { + return streams[streams.length - 1]; +} + +describe("chat-store viewer", () => { + beforeEach(() => { + streams = []; + convCounter = 0; + }); + + it("renders a sent turn from the server stream (echo consumed, no dup)", async () => { + const store = createChatStore(); + await store.sendTurn("draft-1", { text: "hello" }); + const s = latestStream(); + + // Server echoes the user message (consumed by the optimistic placeholder), + // then streams the assistant. + s.onEvent("user.message", { content: "hello" }, 1); + s.onEvent("text.delta", { text: "hi " }, 2); + s.onEvent("text.delta", { text: "there" }, 3); + + const snap = store.getSnapshot("draft-1"); + const users = snap.messages.filter((m) => m.role === "user"); + expect(users).toHaveLength(1); // not duplicated + expect(users[0].content).toBe("hello"); + expect(lastAssistant(snap.messages)?.content).toBe("hi there"); + }); + + it("isolates concurrent turns into their own slices", async () => { + const store = createChatStore(); + await store.sendTurn("kA", { text: "a" }); + const aStream = latestStream(); + await store.sendTurn("kB", { text: "b" }); + const bStream = latestStream(); + + aStream.onEvent("user.message", { content: "a" }, 1); + aStream.onEvent("text.delta", { text: "a1" }, 2); + bStream.onEvent("user.message", { content: "b" }, 1); + bStream.onEvent("text.delta", { text: "b1" }, 2); + aStream.onEvent("text.delta", { text: "a2" }, 3); + + expect(lastAssistant(store.getSnapshot("kA").messages)?.content).toBe("a1a2"); + expect(lastAssistant(store.getSnapshot("kB").messages)?.content).toBe("b1"); + }); + + it("remaps a draft to the real conversation id", async () => { + const store = createChatStore(); + const draft = freshDraftKey(); + const seen: string[] = []; + await store.sendTurn(draft, { text: "hi" }, { onConversationId: (id) => seen.push(id) }); + expect(seen).toEqual(["conv_1"]); + expect(store.getSnapshot(draft).conversationId).toBe("conv_1"); + // The real id resolves to the same live slice. + latestStream().onEvent("user.message", { content: "hi" }, 1); + latestStream().onEvent("text.delta", { text: "yo" }, 2); + expect(lastAssistant(store.getSnapshot("conv_1").messages)?.content).toBe("yo"); + }); + + it("enforces per-slice single-flight", async () => { + const store = createChatStore(); + const p1 = store.sendTurn("kA", { text: "first" }); + const p2 = store.sendTurn("kA", { text: "second" }); // ignored — already streaming + await Promise.all([p1, p2]); + // Only one turn started → one stream opened. + expect(streams).toHaveLength(1); + }); + + it("finalizes on the terminal done event and closes the stream", async () => { + const store = createChatStore(); + await store.sendTurn("kA", { text: "go" }); + const s = latestStream(); + s.onEvent("user.message", { content: "go" }, 1); + s.onEvent("text.delta", { text: "partial" }, 2); + s.onEvent("done", { response: "final answer", conversationId: "conv_1" }, 3); + + const snap = store.getSnapshot("kA"); + expect(snap.isStreaming).toBe(false); + expect(lastAssistant(snap.messages)?.content).toBe("final answer"); + expect(s.closed).toBe(true); + }); + + it("does not clobber a slice that is streaming on loadConversation", async () => { + const store = createChatStore(); + await store.sendTurn("conv_1", { text: "go" }); + latestStream().onEvent("user.message", { content: "go" }, 1); + latestStream().onEvent("text.delta", { text: "streaming-text" }, 2); + + await store.loadConversation("conv_1"); + expect(lastAssistant(store.getSnapshot("conv_1").messages)?.content).toBe("streaming-text"); + }); + + it("loads persisted history into an idle slice and trims a stale in-flight turn on resume", async () => { + const store = createChatStore(); + await store.loadConversation("conv_X"); + const s = latestStream(); + // Server says a turn is in flight → the stale in-flight turn (last user + // message + after) is trimmed, then replay rebuilds it. + s.onSubscribed?.({ isActive: true, activeSeq: 2 }); + // After trim, the loaded "loaded-q"/"loaded-a" pair: "loaded-q" is the last + // user message, so it + the trailing assistant are dropped. + expect(store.getSnapshot("conv_X").messages).toHaveLength(0); + // Server says a turn is active → the streaming indicator shows immediately, + // before any replayed event arrives. + expect(store.getSnapshot("conv_X").isStreaming).toBe(true); + + s.onEvent("user.message", { content: "loaded-q" }, 1); + s.onEvent("text.delta", { text: "fresh" }, 2); + expect(lastAssistant(store.getSnapshot("conv_X").messages)?.content).toBe("fresh"); + }); + + it("closes an idle resume connection when nothing is in flight", async () => { + const store = createChatStore(); + await store.loadConversation("conv_Y"); + const s = latestStream(); + s.onSubscribed?.({ isActive: false, activeSeq: 0 }); + expect(s.closed).toBe(true); + // History still present. + expect(store.getSnapshot("conv_Y").messages).toHaveLength(LOADED.length); + }); + + it("tracks streaming ids and clears on terminal", async () => { + const store = createChatStore(); + await store.sendTurn(freshDraftKey(), { text: "a" }); + latestStream().onEvent("user.message", { content: "a" }, 1); + expect(store.getStreamingIds()).toEqual(["conv_1"]); + latestStream().onEvent("done", { response: "x", conversationId: "conv_1" }, 2); + expect(store.getStreamingIds()).toEqual([]); + }); + + it("caps idle slices via LRU but keeps streaming ones", async () => { + const store = createChatStore(); + await store.sendTurn(freshDraftKey(), { text: "go" }); + latestStream().onEvent("user.message", { content: "go" }, 1); + for (let i = 0; i < 60; i++) store.ensureSlice(`idle-${i}`); + expect(store.sliceCount()).toBeLessThanOrEqual(30); + expect(store.getSnapshot("conv_1").isStreaming).toBe(true); + }); + + it("reset drops every slice and closes streams", async () => { + const store = createChatStore(); + await store.sendTurn("kA", { text: "a" }); + const s = latestStream(); + expect(store.sliceCount()).toBeGreaterThan(0); + store.reset(); + expect(store.sliceCount()).toBe(0); + expect(s.closed).toBe(true); + expect(store.getSnapshot("conv_1").messages).toEqual([]); + }); + + it("probeConversation lights a dot for an active conversation (no history fetch)", () => { + const store = createChatStore(); + store.probeConversation("conv_live"); + latestStream().onSubscribed?.({ isActive: true, activeSeq: 3 }); + + expect(store.getStreamingIds()).toEqual(["conv_live"]); + // No message history was fetched — only the probe subscription. + expect(store.getSnapshot("conv_live").messages).toEqual([]); + }); + + it("probeConversation closes and shows no dot for an inactive conversation", () => { + const store = createChatStore(); + store.probeConversation("conv_done"); + const s = latestStream(); + s.onSubscribed?.({ isActive: false, activeSeq: 0 }); + + expect(store.getStreamingIds()).toEqual([]); + expect(s.closed).toBe(true); + }); + + it("opening a probed conversation still loads full history", async () => { + const store = createChatStore(); + store.probeConversation("conv_x"); + latestStream().onSubscribed?.({ isActive: true, activeSeq: 3 }); + // Probe left it unhydrated despite streaming — loadConversation must fetch. + await store.loadConversation("conv_x"); + expect(lastAssistant(store.getSnapshot("conv_x").messages)?.content).toBe("loaded-a"); + }); + + it("setTitle updates a conversation's slice title (live conversation.title SSE)", async () => { + const store = createChatStore(); + await store.sendTurn("kA", { text: "a" }); + latestStream().onEvent("chat.start", { conversationId: "A" }, 1); + expect(store.getSnapshot("A").title).toBeNull(); + + store.setTitle("A", "Library Paranoia Joke"); + expect(store.getSnapshot("A").title).toBe("Library Paranoia Joke"); + }); + + it("setTitle is a no-op for a conversation with no slice in this tab", () => { + const store = createChatStore(); + store.setTitle("conv_absent", "Whatever"); + expect(store.getSnapshot("conv_absent").title).toBeNull(); + }); + + it("does not duplicate a finished turn whose grace-buffer replay still arrives", async () => { + const store = createChatStore(); + // Disk already has the completed turn. + await store.loadConversation("conv_done"); + expect(store.getSnapshot("conv_done").messages).toEqual(LOADED); + + // Resume finds no active turn, but the server still replays the recently + // finished turn from its grace buffer. Those events must be dropped, not + // re-appended on top of the disk history. + const s = latestStream(); + s.onSubscribed?.({ isActive: false, activeSeq: 0 }); + s.onEvent("user.message", { content: "loaded-q" }, 1); + s.onEvent("text.delta", { text: "loaded-a" }, 2); + s.onEvent("done", { conversationId: "conv_done", response: "loaded-a" }, 3); + + expect(store.getSnapshot("conv_done").messages).toEqual(LOADED); + }); +}); diff --git a/web/test/chatBleed.test.tsx b/web/test/chatBleed.test.tsx new file mode 100644 index 00000000..351eca0b --- /dev/null +++ b/web/test/chatBleed.test.tsx @@ -0,0 +1,129 @@ +import { act, renderHook } from "@testing-library/react"; +import { beforeEach, describe, expect, it, mock } from "bun:test"; +import type { ReactNode } from "react"; +import { ChatProvider, useChatContext } from "../src/context/ChatContext.tsx"; +import type { ChatMessage } from "../src/hooks/useChat.ts"; + +// --------------------------------------------------------------------------- +// Regression for #254 under the server-authoritative model: a turn streaming +// in conversation A must NOT bleed into B when the user switches mid-turn. +// Each conversation is a viewer over its own server stream; switching away +// keeps A's stream filling A's slice in the background. +// --------------------------------------------------------------------------- + +type StreamCb = (type: string, data: unknown, seq: number) => void; +const streamsByConv = new Map(); +const subscribedByConv = new Map void>(); + +const B_MESSAGES: ChatMessage[] = [ + { role: "user", content: "b-question" }, + { role: "assistant", content: "b-answer", blocks: [{ type: "text", text: "b-answer" }] }, +]; + +mock.module("../src/api/conversation-stream", () => ({ + connectConversationStream: (opts: { + conversationId: string; + onEvent: StreamCb; + onSubscribed?: (info: { isActive: boolean; activeSeq: number }) => void; + }) => { + streamsByConv.set(opts.conversationId, opts.onEvent); + if (opts.onSubscribed) subscribedByConv.set(opts.conversationId, opts.onSubscribed); + return { + close() { + streamsByConv.delete(opts.conversationId); + subscribedByConv.delete(opts.conversationId); + }, + }; + }, +})); + +const actualClient = await import("../src/api/client"); +mock.module("../src/api/client", () => ({ + ...actualClient, + startChatTurn: () => Promise.resolve({ conversationId: "conv-A" }), + startChatTurnMultipart: () => Promise.resolve({ conversationId: "conv-A" }), + cancelChatTurn: () => Promise.resolve(), + callTool: (server: string, action: string, args?: Record) => { + if (server === "conversations" && action === "get") { + return Promise.resolve({ + isError: false, + structuredContent: { metadata: { id: args?.id }, messages: B_MESSAGES }, + }); + } + return Promise.resolve({ isError: false, structuredContent: {} }); + }, +})); + +function wrapper({ children }: { children: ReactNode }) { + return {children}; +} + +function lastAssistant(messages: ChatMessage[]): ChatMessage | undefined { + for (let i = messages.length - 1; i >= 0; i--) { + if (messages[i].role === "assistant") return messages[i]; + } + return undefined; +} + +describe("#254 mid-turn conversation switch (server-authoritative)", () => { + beforeEach(() => { + streamsByConv.clear(); + subscribedByConv.clear(); + }); + + it("does not bleed A's streaming deltas into conversation B", async () => { + const { result } = renderHook(() => useChatContext(), { wrapper }); + + await act(async () => { + await result.current.sendMessage("a-question"); + }); + + act(() => { + streamsByConv.get("conv-A")?.("user.message", { content: "a-question" }, 1); + streamsByConv.get("conv-A")?.("text.delta", { text: "A-part1" }, 2); + }); + + await act(async () => { + await result.current.loadConversation("conv-B"); + }); + act(() => { + subscribedByConv.get("conv-B")?.({ isActive: false, activeSeq: 0 }); + }); + + expect(result.current.conversationId).toBe("conv-B"); + expect(lastAssistant(result.current.messages)?.content).toBe("b-answer"); + + // A keeps streaming in the background. + act(() => { + streamsByConv.get("conv-A")?.("text.delta", { text: "A-part2" }, 3); + }); + + expect(lastAssistant(result.current.messages)?.content).toBe("b-answer"); + }); + + it("keeps A's background stream so switching back shows the response", async () => { + const { result } = renderHook(() => useChatContext(), { wrapper }); + + await act(async () => { + await result.current.sendMessage("a-question"); + }); + act(() => { + streamsByConv.get("conv-A")?.("user.message", { content: "a-question" }, 1); + streamsByConv.get("conv-A")?.("text.delta", { text: "A1" }, 2); + }); + + await act(async () => { + await result.current.loadConversation("conv-B"); + }); + act(() => { + subscribedByConv.get("conv-B")?.({ isActive: false, activeSeq: 0 }); + streamsByConv.get("conv-A")?.("text.delta", { text: "A2" }, 3); + }); + + await act(async () => { + await result.current.loadConversation("conv-A"); + }); + expect(result.current.conversationId).toBe("conv-A"); + expect(lastAssistant(result.current.messages)?.content).toBe("A1A2"); + }); +}); diff --git a/web/test/inlineError.test.tsx b/web/test/inlineError.test.tsx index 0079baed..92e67bab 100644 --- a/web/test/inlineError.test.tsx +++ b/web/test/inlineError.test.tsx @@ -1,183 +1,119 @@ -import { describe, expect, it, mock, beforeEach } from "bun:test"; -import { renderHook, act } from "@testing-library/react"; +import { act, renderHook } from "@testing-library/react"; +import { beforeEach, describe, expect, it, mock } from "bun:test"; import type { ReactNode } from "react"; import { ChatProvider, useChatContext } from "../src/context/ChatContext.tsx"; // --------------------------------------------------------------------------- -// Mock streamChat so we can control SSE events in tests +// Inline error UX under the server-authoritative path. We capture the turn +// stream's onEvent and push synthetic server events. // --------------------------------------------------------------------------- -type StreamCallback = (type: string, data: unknown) => void; +type StreamCb = (type: string, data: unknown, seq: number) => void; +let capturedOnEvent: StreamCb | null = null; -let capturedCallback: StreamCallback | null = null; -let resolveStream: (() => void) | null = null; -let rejectStream: ((err: Error) => void) | null = null; +mock.module("../src/api/conversation-stream", () => ({ + connectConversationStream: (opts: { onEvent: StreamCb }) => { + capturedOnEvent = opts.onEvent; + return { close() {} }; + }, +})); +const actualClient = await import("../src/api/client"); mock.module("../src/api/client", () => ({ - streamChat: (_req: unknown, cb: StreamCallback) => { - capturedCallback = cb; - return new Promise((resolve, reject) => { - resolveStream = resolve; - rejectStream = reject; - }); - }, - getConversationHistory: mock(() => - Promise.resolve({ conversationId: "c1", messages: [] }), - ), + ...actualClient, + startChatTurn: () => Promise.resolve({ conversationId: "c1" }), + startChatTurnMultipart: () => Promise.resolve({ conversationId: "c1" }), + cancelChatTurn: () => Promise.resolve(), })); -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - function wrapper({ children }: { children: ReactNode }) { - return {children}; + return {children}; } -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- +let seq = 0; +function emit(type: string, data: unknown): void { + seq += 1; + capturedOnEvent?.(type, data, seq); +} describe("inline error UX", () => { - beforeEach(() => { - capturedCallback = null; - resolveStream = null; - rejectStream = null; - }); - - it("stream error event stamps error on last assistant message, not banner", async () => { - const { result } = renderHook(() => useChatContext(), { wrapper }); - - act(() => { - result.current.sendMessage("hello"); - }); - await act(async () => {}); - - // Simulate some streaming content first - act(() => { - capturedCallback?.("text.delta", { text: "Here is my response" }); - }); - - // Fire SSE error event - act(() => { - capturedCallback?.("error", { - error: "json_parse", - message: "JSON Parse error: Unable to parse JSON string", - }); - }); - - // Error should be on the last assistant message - const lastMsg = result.current.messages[result.current.messages.length - 1]; - expect(lastMsg?.role).toBe("assistant"); - expect(lastMsg?.error).toBe("JSON Parse error: Unable to parse JSON string"); - - // Banner error should NOT be set - expect(result.current.error).toBeNull(); - }); - - it("simulateError is a no-op when there are no messages", () => { - const { result } = renderHook(() => useChatContext(), { wrapper }); - - expect(result.current.messages).toHaveLength(0); - - act(() => { - result.current.simulateError("Something broke"); - }); - - // No messages to stamp on — stays empty - expect(result.current.messages).toHaveLength(0); - expect(result.current.isStreaming).toBe(false); - expect(result.current.streamingState).toBeNull(); - }); - - it("simulateError stamps on existing assistant message", async () => { - const { result } = renderHook(() => useChatContext(), { wrapper }); - - act(() => { - result.current.sendMessage("hello"); - }); - await act(async () => {}); - - act(() => { - capturedCallback?.("text.delta", { text: "response text" }); - }); - - // Complete the stream so isStreaming is false - act(() => { - capturedCallback?.("done", { - response: "response text", - conversationId: "c1", - toolCalls: [], - inputTokens: 10, - outputTokens: 5, - stopReason: "complete", - }); - }); - act(() => { - resolveStream?.(); - }); - await act(async () => {}); - - const msgCountBefore = result.current.messages.length; - - act(() => { - result.current.simulateError("Simulated crash"); - }); - - // Should stamp on existing message, not add a new one - expect(result.current.messages).toHaveLength(msgCountBefore); - const lastMsg = result.current.messages[result.current.messages.length - 1]; - expect(lastMsg?.role).toBe("assistant"); - expect(lastMsg?.error).toBe("Simulated crash"); - }); - - it("retryLastMessage removes failed pair and re-sends", async () => { - const { result } = renderHook(() => useChatContext(), { wrapper }); - - // Send a message - act(() => { - result.current.sendMessage("try this"); - }); - await act(async () => {}); - - // Simulate some content then error - act(() => { - capturedCallback?.("text.delta", { text: "partial" }); - }); - act(() => { - capturedCallback?.("error", { - error: "crash", - message: "Engine crashed", - }); - }); - - // Complete the stream promise so isStreaming clears - act(() => { - resolveStream?.(); - }); - await act(async () => {}); - - // Should have user + errored assistant - expect(result.current.messages).toHaveLength(2); - expect(result.current.messages[1].error).toBe("Engine crashed"); - - // Reset mocks for the retry - capturedCallback = null; - resolveStream = null; - - // Retry - act(() => { - result.current.retryLastMessage(); - }); - await act(async () => {}); - - // retryLastMessage removes the failed pair and triggers a new send. - // After retry fires, we should have a new user + assistant placeholder. - // The callback should be captured again from the new sendMessage call. - // Give it another tick for the effect to fire sendMessage - await act(async () => {}); - - // The retry effect should have fired sendMessage, creating new messages - expect(result.current.isStreaming).toBe(true); - }); + beforeEach(() => { + capturedOnEvent = null; + seq = 0; + }); + + it("stream error event stamps error on last assistant message, not banner", async () => { + const { result } = renderHook(() => useChatContext(), { wrapper }); + await act(async () => { + await result.current.sendMessage("hello"); + }); + + act(() => emit("text.delta", { text: "Here is my response" })); + act(() => + emit("error", { + error: "json_parse", + message: "JSON Parse error: Unable to parse JSON string", + }), + ); + + const lastMsg = result.current.messages[result.current.messages.length - 1]; + expect(lastMsg?.role).toBe("assistant"); + expect(lastMsg?.error).toBe("JSON Parse error: Unable to parse JSON string"); + expect(result.current.error).toBeNull(); + }); + + it("simulateError is a no-op when there are no messages", () => { + const { result } = renderHook(() => useChatContext(), { wrapper }); + expect(result.current.messages).toHaveLength(0); + act(() => { + result.current.simulateError("Something broke"); + }); + expect(result.current.messages).toHaveLength(0); + expect(result.current.isStreaming).toBe(false); + expect(result.current.streamingState).toBeNull(); + }); + + it("simulateError stamps on existing assistant message", async () => { + const { result } = renderHook(() => useChatContext(), { wrapper }); + await act(async () => { + await result.current.sendMessage("hello"); + }); + act(() => emit("text.delta", { text: "response text" })); + act(() => + emit("done", { + response: "response text", + conversationId: "c1", + toolCalls: [], + stopReason: "complete", + }), + ); + + const msgCountBefore = result.current.messages.length; + act(() => { + result.current.simulateError("Simulated crash"); + }); + expect(result.current.messages).toHaveLength(msgCountBefore); + const lastMsg = result.current.messages[result.current.messages.length - 1]; + expect(lastMsg?.role).toBe("assistant"); + expect(lastMsg?.error).toBe("Simulated crash"); + }); + + it("retryLastMessage removes failed pair and re-sends", async () => { + const { result } = renderHook(() => useChatContext(), { wrapper }); + await act(async () => { + await result.current.sendMessage("try this"); + }); + act(() => emit("text.delta", { text: "partial" })); + act(() => emit("error", { error: "crash", message: "Engine crashed" })); + + expect(result.current.messages).toHaveLength(2); + expect(result.current.messages[1].error).toBe("Engine crashed"); + + await act(async () => { + result.current.retryLastMessage(); + }); + await act(async () => {}); + + expect(result.current.isStreaming).toBe(true); + }); }); diff --git a/web/test/streamingState.test.tsx b/web/test/streamingState.test.tsx index 5f8d7877..5f8f8d58 100644 --- a/web/test/streamingState.test.tsx +++ b/web/test/streamingState.test.tsx @@ -1,305 +1,193 @@ -import { describe, expect, it, mock, beforeEach } from "bun:test"; -import { renderHook, act } from "@testing-library/react"; +import { act, renderHook } from "@testing-library/react"; +import { beforeEach, describe, expect, it, mock } from "bun:test"; import type { ReactNode } from "react"; import { ChatProvider, useChatContext } from "../src/context/ChatContext.tsx"; import type { StreamingState } from "../src/hooks/useChat.ts"; // --------------------------------------------------------------------------- -// Mock streamChat so we can control SSE events in tests +// Drive the streaming state machine through the server-authoritative path: +// sendMessage → startChatTurn (POST) → subscribe via connectConversationStream. +// We capture the stream's onEvent and push synthetic server events. // --------------------------------------------------------------------------- -type StreamCallback = (type: string, data: unknown) => void; +type StreamCb = (type: string, data: unknown, seq: number) => void; +let capturedOnEvent: StreamCb | null = null; -let capturedCallback: StreamCallback | null = null; -let resolveStream: (() => void) | null = null; +mock.module("../src/api/conversation-stream", () => ({ + connectConversationStream: (opts: { onEvent: StreamCb }) => { + capturedOnEvent = opts.onEvent; + return { close() {} }; + }, +})); +const actualClient = await import("../src/api/client"); mock.module("../src/api/client", () => ({ - streamChat: (_req: unknown, cb: StreamCallback) => { - capturedCallback = cb; - return new Promise((resolve) => { - resolveStream = resolve; - }); - }, - getConversationHistory: mock(() => - Promise.resolve({ conversationId: "c1", messages: [] }), - ), + ...actualClient, + startChatTurn: () => Promise.resolve({ conversationId: "c1" }), + startChatTurnMultipart: () => Promise.resolve({ conversationId: "c1" }), + cancelChatTurn: () => Promise.resolve(), })); -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - function wrapper({ children }: { children: ReactNode }) { - return {children}; + return {children}; } -function useStreamingState() { - const ctx = useChatContext(); - return ctx; +let seq = 0; +function emit(type: string, data: unknown): void { + seq += 1; + capturedOnEvent?.(type, data, seq); } -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - describe("streamingState state machine", () => { - beforeEach(() => { - capturedCallback = null; - resolveStream = null; - }); - - it("starts as null", () => { - const { result } = renderHook(() => useStreamingState(), { wrapper }); - expect(result.current.streamingState).toBeNull(); - }); - - it("transitions to thinking when sendMessage is called", async () => { - const { result } = renderHook(() => useStreamingState(), { wrapper }); - - // sendMessage returns a promise; don't await (stream is pending) - act(() => { - result.current.sendMessage("hello"); - }); - - // Wait a tick for state to settle - await act(async () => {}); - - expect(result.current.streamingState).toBe("thinking" as StreamingState); - }); - - it("transitions thinking → streaming on first text.delta", async () => { - const { result } = renderHook(() => useStreamingState(), { wrapper }); - - act(() => { - result.current.sendMessage("hello"); - }); - await act(async () => {}); - - expect(result.current.streamingState).toBe("thinking"); - - act(() => { - capturedCallback?.("text.delta", { text: "Hi" }); - }); - - expect(result.current.streamingState).toBe("streaming"); - }); - - it("transitions streaming → working on tool.start", async () => { - const { result } = renderHook(() => useStreamingState(), { wrapper }); - - act(() => { - result.current.sendMessage("hello"); - }); - await act(async () => {}); - - act(() => { - capturedCallback?.("text.delta", { text: "Let me check" }); - }); - expect(result.current.streamingState).toBe("streaming"); - - act(() => { - capturedCallback?.("tool.start", { id: "t1", name: "search" }); - }); - expect(result.current.streamingState).toBe("working"); - }); - - it("transitions working → analyzing on last tool.done", async () => { - const { result } = renderHook(() => useStreamingState(), { wrapper }); - - act(() => { - result.current.sendMessage("hello"); - }); - await act(async () => {}); - - act(() => { - capturedCallback?.("text.delta", { text: "x" }); - }); - act(() => { - capturedCallback?.("tool.start", { id: "t1", name: "search" }); - }); - expect(result.current.streamingState).toBe("working"); - - act(() => { - capturedCallback?.("tool.done", { id: "t1", name: "search", ok: true, ms: 100 }); - }); - // No in-flight tools remain → model is inferring on the result. - expect(result.current.streamingState).toBe("analyzing"); - }); - - it("holds working while parallel tools are still in flight, then analyzing", async () => { - const { result } = renderHook(() => useStreamingState(), { wrapper }); - - act(() => { - result.current.sendMessage("hello"); - }); - await act(async () => {}); - - act(() => { - capturedCallback?.("tool.start", { id: "a", name: "search" }); - capturedCallback?.("tool.start", { id: "b", name: "fetch" }); - }); - expect(result.current.streamingState).toBe("working"); - - // First of two completes — the other is still running, so stay `working`. - act(() => { - capturedCallback?.("tool.done", { id: "a", name: "search", ok: true, ms: 10 }); - }); - expect(result.current.streamingState).toBe("working"); - - // Last one lands → flip to `analyzing`. - act(() => { - capturedCallback?.("tool.done", { id: "b", name: "fetch", ok: false, ms: 725 }); - }); - expect(result.current.streamingState).toBe("analyzing"); - }); - - it("transitions analyzing → streaming on the next text.delta", async () => { - const { result } = renderHook(() => useStreamingState(), { wrapper }); - - act(() => { - result.current.sendMessage("hello"); - }); - await act(async () => {}); - - act(() => { - capturedCallback?.("tool.start", { id: "t1", name: "search" }); - capturedCallback?.("tool.done", { id: "t1", name: "search", ok: true, ms: 10 }); - }); - expect(result.current.streamingState).toBe("analyzing"); - - act(() => { - capturedCallback?.("text.delta", { text: "Based on that…" }); - }); - expect(result.current.streamingState).toBe("streaming"); - }); - - it("transitions analyzing → working when the model calls another tool", async () => { - const { result } = renderHook(() => useStreamingState(), { wrapper }); - - act(() => { - result.current.sendMessage("hello"); - }); - await act(async () => {}); - - act(() => { - capturedCallback?.("tool.start", { id: "t1", name: "search" }); - capturedCallback?.("tool.done", { id: "t1", name: "search", ok: true, ms: 10 }); - }); - expect(result.current.streamingState).toBe("analyzing"); - - act(() => { - capturedCallback?.("tool.start", { id: "t2", name: "fetch" }); - }); - expect(result.current.streamingState).toBe("working"); - }); - - it("transitions to null on done event", async () => { - const { result } = renderHook(() => useStreamingState(), { wrapper }); - - act(() => { - result.current.sendMessage("hello"); - }); - await act(async () => {}); - - act(() => { - capturedCallback?.("text.delta", { text: "Hi" }); - }); - expect(result.current.streamingState).toBe("streaming"); - - act(() => { - capturedCallback?.("done", { - conversationId: "c1", - response: "Hi", - }); - }); - expect(result.current.streamingState).toBeNull(); - }); - - it("transitions to null on error event", async () => { - const { result } = renderHook(() => useStreamingState(), { wrapper }); - - act(() => { - result.current.sendMessage("hello"); - }); - await act(async () => {}); - - expect(result.current.streamingState).toBe("thinking"); - - act(() => { - capturedCallback?.("error", { error: "fail", message: "fail" }); - }); - expect(result.current.streamingState).toBeNull(); - }); - - it("transitions to null in finally after stream resolves", async () => { - const { result } = renderHook(() => useStreamingState(), { wrapper }); - - act(() => { - result.current.sendMessage("hello"); - }); - await act(async () => {}); - - expect(result.current.streamingState).toBe("thinking"); - - // Resolve the stream promise (triggers finally block) - await act(async () => { - resolveStream?.(); - }); - - expect(result.current.streamingState).toBeNull(); - }); - - it("newConversation resets streamingState to null", async () => { - const { result } = renderHook(() => useStreamingState(), { wrapper }); - - act(() => { - result.current.newConversation(); - }); - - expect(result.current.streamingState).toBeNull(); - }); - - it("full cycle: thinking → streaming → working → analyzing → streaming → null", async () => { - const { result } = renderHook(() => useStreamingState(), { wrapper }); - - act(() => { - result.current.sendMessage("hello"); - }); - await act(async () => {}); - expect(result.current.streamingState).toBe("thinking"); - - act(() => { - capturedCallback?.("text.delta", { text: "Let me " }); - }); - expect(result.current.streamingState).toBe("streaming"); - - act(() => { - capturedCallback?.("tool.start", { id: "t1", name: "lookup" }); - }); - expect(result.current.streamingState).toBe("working"); - - act(() => { - capturedCallback?.("tool.done", { id: "t1", name: "lookup", ok: true, ms: 50 }); - }); - expect(result.current.streamingState).toBe("analyzing"); - - act(() => { - capturedCallback?.("text.delta", { text: "here you go" }); - }); - expect(result.current.streamingState).toBe("streaming"); - - act(() => { - capturedCallback?.("done", { - conversationId: "c1", - response: "Let me here you go", - }); - }); - expect(result.current.streamingState).toBeNull(); - - // Resolve the stream so the finally block runs - await act(async () => { - resolveStream?.(); - }); - expect(result.current.streamingState).toBeNull(); - }); + beforeEach(() => { + capturedOnEvent = null; + seq = 0; + }); + + it("starts as null", () => { + const { result } = renderHook(() => useChatContext(), { wrapper }); + expect(result.current.streamingState).toBeNull(); + }); + + it("transitions to thinking when sendMessage is called", async () => { + const { result } = renderHook(() => useChatContext(), { wrapper }); + await act(async () => { + await result.current.sendMessage("hello"); + }); + expect(result.current.streamingState).toBe("thinking" as StreamingState); + }); + + it("transitions thinking → streaming on first text.delta", async () => { + const { result } = renderHook(() => useChatContext(), { wrapper }); + await act(async () => { + await result.current.sendMessage("hello"); + }); + expect(result.current.streamingState).toBe("thinking"); + act(() => emit("text.delta", { text: "Hi" })); + expect(result.current.streamingState).toBe("streaming"); + }); + + it("transitions streaming → working on tool.start", async () => { + const { result } = renderHook(() => useChatContext(), { wrapper }); + await act(async () => { + await result.current.sendMessage("hello"); + }); + act(() => emit("text.delta", { text: "Let me check" })); + expect(result.current.streamingState).toBe("streaming"); + act(() => emit("tool.start", { id: "t1", name: "search" })); + expect(result.current.streamingState).toBe("working"); + }); + + it("transitions working → analyzing on last tool.done", async () => { + const { result } = renderHook(() => useChatContext(), { wrapper }); + await act(async () => { + await result.current.sendMessage("hello"); + }); + act(() => emit("text.delta", { text: "x" })); + act(() => emit("tool.start", { id: "t1", name: "search" })); + expect(result.current.streamingState).toBe("working"); + act(() => emit("tool.done", { id: "t1", name: "search", ok: true, ms: 100 })); + expect(result.current.streamingState).toBe("analyzing"); + }); + + it("holds working while parallel tools are still in flight, then analyzing", async () => { + const { result } = renderHook(() => useChatContext(), { wrapper }); + await act(async () => { + await result.current.sendMessage("hello"); + }); + act(() => { + emit("tool.start", { id: "a", name: "search" }); + emit("tool.start", { id: "b", name: "fetch" }); + }); + expect(result.current.streamingState).toBe("working"); + act(() => emit("tool.done", { id: "a", name: "search", ok: true, ms: 10 })); + expect(result.current.streamingState).toBe("working"); + act(() => emit("tool.done", { id: "b", name: "fetch", ok: false, ms: 725 })); + expect(result.current.streamingState).toBe("analyzing"); + }); + + it("transitions analyzing → streaming on the next text.delta", async () => { + const { result } = renderHook(() => useChatContext(), { wrapper }); + await act(async () => { + await result.current.sendMessage("hello"); + }); + act(() => { + emit("tool.start", { id: "t1", name: "search" }); + emit("tool.done", { id: "t1", name: "search", ok: true, ms: 10 }); + }); + expect(result.current.streamingState).toBe("analyzing"); + act(() => emit("text.delta", { text: "Based on that…" })); + expect(result.current.streamingState).toBe("streaming"); + }); + + it("transitions analyzing → working when the model calls another tool", async () => { + const { result } = renderHook(() => useChatContext(), { wrapper }); + await act(async () => { + await result.current.sendMessage("hello"); + }); + act(() => { + emit("tool.start", { id: "t1", name: "search" }); + emit("tool.done", { id: "t1", name: "search", ok: true, ms: 10 }); + }); + expect(result.current.streamingState).toBe("analyzing"); + act(() => emit("tool.start", { id: "t2", name: "fetch" })); + expect(result.current.streamingState).toBe("working"); + }); + + it("transitions to null on done event", async () => { + const { result } = renderHook(() => useChatContext(), { wrapper }); + await act(async () => { + await result.current.sendMessage("hello"); + }); + act(() => emit("text.delta", { text: "Hi" })); + expect(result.current.streamingState).toBe("streaming"); + act(() => emit("done", { conversationId: "c1", response: "Hi" })); + expect(result.current.streamingState).toBeNull(); + }); + + it("transitions to null on error event", async () => { + const { result } = renderHook(() => useChatContext(), { wrapper }); + await act(async () => { + await result.current.sendMessage("hello"); + }); + expect(result.current.streamingState).toBe("thinking"); + act(() => emit("error", { error: "fail", message: "fail" })); + expect(result.current.streamingState).toBeNull(); + }); + + it("transitions to null on cancelled event", async () => { + const { result } = renderHook(() => useChatContext(), { wrapper }); + await act(async () => { + await result.current.sendMessage("hello"); + }); + expect(result.current.streamingState).toBe("thinking"); + act(() => emit("cancelled", {})); + expect(result.current.streamingState).toBeNull(); + }); + + it("newConversation resets streamingState to null", () => { + const { result } = renderHook(() => useChatContext(), { wrapper }); + act(() => { + result.current.newConversation(); + }); + expect(result.current.streamingState).toBeNull(); + }); + + it("full cycle: thinking → streaming → working → analyzing → streaming → null", async () => { + const { result } = renderHook(() => useChatContext(), { wrapper }); + await act(async () => { + await result.current.sendMessage("hello"); + }); + expect(result.current.streamingState).toBe("thinking"); + act(() => emit("text.delta", { text: "Let me " })); + expect(result.current.streamingState).toBe("streaming"); + act(() => emit("tool.start", { id: "t1", name: "lookup" })); + expect(result.current.streamingState).toBe("working"); + act(() => emit("tool.done", { id: "t1", name: "lookup", ok: true, ms: 50 })); + expect(result.current.streamingState).toBe("analyzing"); + act(() => emit("text.delta", { text: "here you go" })); + expect(result.current.streamingState).toBe("streaming"); + act(() => emit("done", { conversationId: "c1", response: "Let me here you go" })); + expect(result.current.streamingState).toBeNull(); + }); }); From fd7fceacf6485263684ecb67b196f9c97a54f50a Mon Sep 17 00:00:00 2001 From: Mason <31372737+Ovaculos@users.noreply.github.com> Date: Mon, 25 May 2026 10:25:43 -0600 Subject: [PATCH 04/26] feat(conversations): live streaming dots + flicker-free list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Per-row streaming indicator: read host-pushed streamingConversationIds (useHostContext) and render a pulsing dot on conversations with an in-flight turn. - No more list flicker on updates: data.changed refreshes run in the background (no skeleton swap) and only for conversation changes — other apps' data.changed are ignored. Skeleton stays for initial load + view switches. - New conversations appear immediately: ConversationIndex.flushPending() picks up a just-created file on the read path instead of racing the fs.watch debounce. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/bundles/conversations/CHANGELOG.md | 17 +++++ src/bundles/conversations/src/index-cache.ts | 23 +++++++ src/bundles/conversations/ui/src/App.tsx | 2 +- .../conversations/ui/src/ConversationList.tsx | 16 ++++- .../conversations/ui/src/Dashboard.tsx | 68 ++++++++++++------- src/bundles/conversations/ui/src/index.css | 18 +++++ src/tools/platform/conversations.ts | 4 ++ test/unit/conversations-index-flush.test.ts | 56 +++++++++++++++ 8 files changed, 178 insertions(+), 26 deletions(-) create mode 100644 test/unit/conversations-index-flush.test.ts diff --git a/src/bundles/conversations/CHANGELOG.md b/src/bundles/conversations/CHANGELOG.md index cb2721be..ba8315aa 100644 --- a/src/bundles/conversations/CHANGELOG.md +++ b/src/bundles/conversations/CHANGELOG.md @@ -1,5 +1,22 @@ # Changelog +## 0.4.0 + +### Changed + +- List no longer flickers on live updates. `data.changed` refreshes now run in + the background (no skeleton swap) and only for conversation changes — other + apps' `data.changed` are ignored. Initial load and view switches still show + the skeleton. + +## 0.3.0 + +### Added + +- Live streaming indicator: a pulsing dot marks any conversation with an + in-flight assistant turn. Driven by host-pushed `streamingConversationIds` + (`useHostContext`), so it reflects real-time tab state without polling. + ## 0.2.0 **Breaking — tool output shape.** The bundle now returns a display-oriented diff --git a/src/bundles/conversations/src/index-cache.ts b/src/bundles/conversations/src/index-cache.ts index c937e2dd..cb4d7d30 100644 --- a/src/bundles/conversations/src/index-cache.ts +++ b/src/bundles/conversations/src/index-cache.ts @@ -105,6 +105,29 @@ export class ConversationIndex { }); } + /** + * Bring the index up to date NOW, bypassing the fs.watch debounce. + * + * Processes any queued watch events immediately, then scans the directory + * for files not yet indexed (a just-created conversation whose watch event + * hasn't fired or debounced yet). Called on the read path so a + * `data.changed`-driven list refresh reflects a brand-new conversation + * deterministically, instead of racing the 500ms watch debounce. + */ + async flushPending(): Promise { + if (this.debounceTimer) { + clearTimeout(this.debounceTimer); + this.debounceTimer = null; + } + await this.processPendingFiles(); + if (!this.dir) return; + for (const filePath of listConversationFiles(this.dir)) { + if (!this.fileToId.has(basename(filePath))) { + await this.indexFile(filePath); + } + } + } + /** Stop watching. */ stopWatching(): void { if (this.watcher) { diff --git a/src/bundles/conversations/ui/src/App.tsx b/src/bundles/conversations/ui/src/App.tsx index 2b23c1bb..d0e86196 100644 --- a/src/bundles/conversations/ui/src/App.tsx +++ b/src/bundles/conversations/ui/src/App.tsx @@ -3,7 +3,7 @@ import { Dashboard } from "./Dashboard"; export function App() { return ( - + ); diff --git a/src/bundles/conversations/ui/src/ConversationList.tsx b/src/bundles/conversations/ui/src/ConversationList.tsx index 8a7f433c..23a9e305 100644 --- a/src/bundles/conversations/ui/src/ConversationList.tsx +++ b/src/bundles/conversations/ui/src/ConversationList.tsx @@ -10,6 +10,8 @@ interface Props { groups: DateGroup[]; activeFilter: FilterKey; totalConversations: number; + /** Conversation ids with an in-flight assistant turn (host-pushed). */ + streamingIds?: Set; onOpen: (id: string) => void; } @@ -18,6 +20,7 @@ export function ConversationList({ groups, activeFilter, totalConversations, + streamingIds, onOpen, }: Props) { if (loading) { @@ -60,10 +63,21 @@ export function ConversationList({ {showSectionLabels &&
{group.label}
} {group.items.map((c) => { const title = c.title || c.preview || c.id; + const isStreaming = streamingIds?.has(c.id) ?? false; return (