From 7b7d6d01cf94ecad1dcaaa4b87670d50fe15790a Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Wed, 21 Oct 2026 19:39:47 +0900 Subject: fix(chat): bound live subscriptions and preserve unconfirmed input --- src/adapters/ws/index.ts | 14 ++- src/adapters/ws/logic.ts | 26 ++++- src/app/App.test.ts | 1 + src/app/chat-registry.svelte.ts | 59 ++++++++--- src/app/open-conversations.svelte.ts | 16 ++- src/app/store.test.ts | 87 +++++++++++++++-- src/app/subscription-budget.test.ts | 20 ++++ src/app/subscription-budget.ts | 15 +++ src/app/surface-protocol.svelte.ts | 30 +++--- src/app/surface-protocol.test.ts | 31 ++++++ src/app/views/ChatPane.svelte | 5 +- src/app/ws-handlers.ts | 37 ++++--- src/core/wire/conformance.test.ts | 8 ++ src/core/wire/conformance.ts | 4 + src/features/chat/adapter/pending-inputs.ts | 49 ++++++++++ src/features/chat/delivery.test.ts | 146 ++++++++++++++++++++++++++++ src/features/chat/index.ts | 3 + src/features/chat/pending-inputs.ts | 12 +++ src/features/chat/store.svelte.ts | 104 ++++++++++++++++++-- src/features/chat/store.test.ts | 1 + src/features/chat/ui/PendingInputs.svelte | 34 +++++++ 21 files changed, 636 insertions(+), 66 deletions(-) create mode 100644 src/app/subscription-budget.test.ts create mode 100644 src/app/subscription-budget.ts create mode 100644 src/features/chat/adapter/pending-inputs.ts create mode 100644 src/features/chat/delivery.test.ts create mode 100644 src/features/chat/pending-inputs.ts create mode 100644 src/features/chat/ui/PendingInputs.svelte (limited to 'src') diff --git a/src/adapters/ws/index.ts b/src/adapters/ws/index.ts index b1e17cf..e1d7d22 100644 --- a/src/adapters/ws/index.ts +++ b/src/adapters/ws/index.ts @@ -1,8 +1,10 @@ import type { + ChatAcceptedMessage, ChatDeltaMessage, ChatErrorMessage, ChatQueuedMessage, ChatResyncRequiredMessage, + ChatSubscriptionRejectedMessage, ConversationCompactedMessage, ConversationOpenMessage, ConversationStatusChangedMessage, @@ -27,7 +29,13 @@ export interface SurfaceSocketOptions { url: string; onMessage: (msg: SurfaceServerMessage) => void; onChat?: ( - msg: ChatDeltaMessage | ChatErrorMessage | ChatQueuedMessage | ChatResyncRequiredMessage, + msg: + | ChatAcceptedMessage + | ChatSubscriptionRejectedMessage + | ChatDeltaMessage + | ChatErrorMessage + | ChatQueuedMessage + | ChatResyncRequiredMessage, ) => void; /** Broadcast when a conversation is "opened" (e.g. CLI `--open` flag). */ onConversationOpen?: (msg: ConversationOpenMessage) => void; @@ -105,6 +113,8 @@ export function createSurfaceSocket(opts: SurfaceSocketOptions): SurfaceSocketHa const msg = parseServerMessage(ev.data); if (msg !== null) { if ( + msg.type === "chat.accepted" || + msg.type === "chat.subscription-rejected" || msg.type === "chat.delta" || msg.type === "chat.error" || msg.type === "chat.queued" || @@ -112,6 +122,8 @@ export function createSurfaceSocket(opts: SurfaceSocketOptions): SurfaceSocketHa ) { opts.onChat?.( msg as + | ChatAcceptedMessage + | ChatSubscriptionRejectedMessage | ChatDeltaMessage | ChatErrorMessage | ChatQueuedMessage diff --git a/src/adapters/ws/logic.ts b/src/adapters/ws/logic.ts index abc0464..d167669 100644 --- a/src/adapters/ws/logic.ts +++ b/src/adapters/ws/logic.ts @@ -25,6 +25,8 @@ const VALID_SERVER_TYPES = new Set([ "surface", "update", "error", + "chat.accepted", + "chat.subscription-rejected", "chat.delta", "chat.error", "chat.queued", @@ -109,6 +111,24 @@ export function parseServerMessage(data: string): WsServerMessage | null { : { type: "error", message: parsed.message }; return msg; } + case "chat.accepted": { + if (typeof parsed.conversationId !== "string" || typeof parsed.requestId !== "string") + return null; + return { + type: "chat.accepted", + conversationId: parsed.conversationId, + requestId: parsed.requestId, + }; + } + case "chat.subscription-rejected": { + if (typeof parsed.conversationId !== "string" || parsed.reason !== "subscription-limit") + return null; + return { + type: "chat.subscription-rejected", + conversationId: parsed.conversationId, + reason: parsed.reason, + }; + } case "chat.delta": { const event = parsed.event; if (!isRecord(event)) return null; @@ -149,7 +169,11 @@ export function parseServerMessage(data: string): WsServerMessage | null { conversationId !== undefined ? { type: "chat.error", conversationId, message: parsed.message } : { type: "chat.error", message: parsed.message }; - return msg; + if (parsed.requestId !== undefined && typeof parsed.requestId !== "string") return null; + return { + ...msg, + ...(typeof parsed.requestId === "string" ? { requestId: parsed.requestId } : {}), + }; } case "conversation.open": { if (typeof parsed.conversationId !== "string") return null; diff --git a/src/app/App.test.ts b/src/app/App.test.ts index 62798d6..42d30ed 100644 --- a/src/app/App.test.ts +++ b/src/app/App.test.ts @@ -470,6 +470,7 @@ describe("App component interaction tests", () => { expect(sentMessages(ws).filter((m: { type: string }) => m.type.startsWith("chat."))).toEqual([ { type: "chat.queue", + requestId: expect.any(String), conversationId, workspaceId: "default", text, diff --git a/src/app/chat-registry.svelte.ts b/src/app/chat-registry.svelte.ts index a9b28ff..63f58ad 100644 --- a/src/app/chat-registry.svelte.ts +++ b/src/app/chat-registry.svelte.ts @@ -3,10 +3,11 @@ import { createLocalStore } from "../adapters/local-storage"; import type { SurfaceSocketHandle } from "../adapters/ws"; import { normalizeChatLimit } from "../core/chunks"; import type { ChatStore, HistorySync, MetricsSync } from "../features/chat"; -import { createChatStore } from "../features/chat"; +import { createChatStore, createPendingInputStorage } from "../features/chat"; import type { ConversationCache } from "../features/conversation-cache"; import type { TabsStore } from "../features/tabs"; import type { ChatLimitResult, CreateAppStoreOptions } from "./store-types"; +import { liveConversationIds } from "./subscription-budget"; import { randomId } from "./uuid"; /** App-owned tab/watch stores and idempotent transcript loading; no singleton state. */ @@ -46,6 +47,7 @@ export function createChatRegistry(deps: { const chatStores = new Map(); // Includes in-flight loads: repeated activation must not start a second load. const loadedConversationIds = new Set(); + const loadingConversationIds = new Set(); // Ephemeral chat stores for MODAL viewers (the heartbeat run-chat modal): a // watch on a conversation's live turn stream WITHOUT opening a tab. Separate @@ -53,6 +55,26 @@ export function createChatRegistry(deps: { // and a tab's conversation reuses its own store (see `watchConversation`). // Deltas are routed here in addition to `chatStores`. const watchStores = new Map(); + const watchedIds = new Set(); + const subscribedIds = new Set(); + + function syncChatSubscriptions(): void { + const desired = liveConversationIds(deps.tabsStore.activeConversationId, watchedIds); + for (const id of subscribedIds) { + if (!desired.has(id)) { + deps.send({ type: "chat.unsubscribe", conversationId: id }); + subscribedIds.delete(id); + } + } + for (const id of desired) { + if (subscribedIds.has(id)) continue; + subscribedIds.add(id); + deps.send({ type: "chat.subscribe", conversationId: id }); + if (loadedConversationIds.has(id) && !loadingConversationIds.has(id)) { + (chatStores.get(id) ?? watchStores.get(id))?.resync(); + } + } + } /** * Coalesce live chat events to at most one fold + render per animation @@ -77,6 +99,11 @@ export function createChatRegistry(deps: { function createChatFor(conversationId: string, model: string, workspaceId: string): ChatStore { return createChatStore({ + ...(deps.storage === undefined + ? {} + : { + pendingInputStorage: createPendingInputStorage(deps.storage, conversationId), + }), conversationId, createRequestId: randomId, model, @@ -128,30 +155,28 @@ export function createChatRegistry(deps: { const store = chatStores.get(conversationId) ?? watchStores.get(conversationId); if (store === undefined) return; loadedConversationIds.add(conversationId); - void store.load(); + loadingConversationIds.add(conversationId); + void store.load().finally(() => loadingConversationIds.delete(conversationId)); } function refreshActiveChat(): void { + syncChatSubscriptions(); const activeId = deps.tabsStore.activeConversationId; if (activeId !== null) ensureLoaded(activeId); activeChat = getActiveChat(); } - /** - * Start watching a conversation's live turn events (`chat.subscribe`). Sent for - * EVERY open conversation — not just the active one — so a backgrounded tab keeps - * streaming a running turn, and a reloaded/second client re-attaches to an - * in-flight turn (the server replays it from `turn-start`). Idempotent server-side; - * the socket queues it until the connection is open. NOT needed right after - * `chat.send` (that auto-subscribes the sending connection). - */ + /** Discovery creates metadata; only the focused chat and explicit viewers watch live. */ function subscribeChat(conversationId: string): void { - deps.send({ type: "chat.subscribe", conversationId }); + void conversationId; + syncChatSubscriptions(); } /** Stop watching a conversation's turn events (`chat.unsubscribe`). Never stops the turn. */ function unsubscribeChat(conversationId: string): void { - deps.send({ type: "chat.unsubscribe", conversationId }); + if (subscribedIds.delete(conversationId)) { + deps.send({ type: "chat.unsubscribe", conversationId }); + } } /** @@ -164,9 +189,11 @@ export function createChatRegistry(deps: { * Pair with {@link unwatchConversation} on close. */ function watchConversation(conversationId: string): ChatStore { + watchedIds.add(conversationId); // An open tab already has a live store + subscription — reuse it. const tabStore = chatStores.get(conversationId); if (tabStore !== undefined) { + syncChatSubscriptions(); ensureLoaded(conversationId); return tabStore; } @@ -174,8 +201,8 @@ export function createChatRegistry(deps: { if (existing !== undefined) return existing; const store = createChatFor(conversationId, deps.activeModel(), deps.activeWorkspaceId()); watchStores.set(conversationId, store); - ensureLoaded(conversationId); subscribeChat(conversationId); + ensureLoaded(conversationId); return store; } @@ -186,6 +213,8 @@ export function createChatRegistry(deps: { * tab strip). Only the ephemeral watch store is disposed + unsubscribed. */ function unwatchConversation(conversationId: string): void { + watchedIds.delete(conversationId); + syncChatSubscriptions(); // A tab reuses its own store — leave it (and its subscription) intact. if (chatStores.has(conversationId)) return; const store = watchStores.get(conversationId); @@ -207,6 +236,10 @@ export function createChatRegistry(deps: { refreshActiveChat, subscribeChat, unsubscribeChat, + reconnect(): void { + subscribedIds.clear(); + syncChatSubscriptions(); + }, watchConversation, unwatchConversation, get draftStore() { diff --git a/src/app/open-conversations.svelte.ts b/src/app/open-conversations.svelte.ts index d97e999..9c8c25b 100644 --- a/src/app/open-conversations.svelte.ts +++ b/src/app/open-conversations.svelte.ts @@ -130,7 +130,15 @@ export function createOpenConversations(deps: { * (closed on another device), and subscribes to `active` conversations' * live streams. */ - async function fetchOpenConversations(): Promise { + let pendingFetch: Promise | undefined; + function fetchOpenConversations(): Promise { + pendingFetch ??= loadOpenConversations().finally(() => { + pendingFetch = undefined; + }); + return pendingFetch; + } + + async function loadOpenConversations(): Promise { try { const res = await fetchImpl(`${httpBase}/conversations?status=active,idle`); if (!res.ok) return; @@ -203,10 +211,14 @@ export function createOpenConversations(deps: { // Remove tabs for conversations no longer active/idle (closed elsewhere). const backendIds = new Set(data.conversations.map((c) => c.id)); for (const tab of tabsStore.tabs) { - if (!backendIds.has(tab.conversationId)) { + if ( + !backendIds.has(tab.conversationId) && + !registry.chatStores.get(tab.conversationId)?.pendingInputs.length + ) { removeTabLocally(tab.conversationId); } } + registry.refreshActiveChat(); } catch (err) { deps.reportError( `Failed to load conversations from the backend.\n\nURL: ${httpBase}/conversations?status=active,idle`, diff --git a/src/app/store.test.ts b/src/app/store.test.ts index 33f93bb..90c11b3 100644 --- a/src/app/store.test.ts +++ b/src/app/store.test.ts @@ -270,8 +270,8 @@ function createFakeStorage(): Storage { getItem(key: string): string | null { return map.get(key) ?? null; }, - key(_index: number): string | null { - return null; + key(index: number): string | null { + return [...map.keys()][index] ?? null; }, removeItem(key: string) { map.delete(key); @@ -2711,13 +2711,14 @@ describe("conversation load-on-demand", () => { ws: WebSocketLike, count = 3, activeConversationId: string | null = "lazy-0", + workspaceCount = 1, ) { const storage = createFakeStorage(); const conversations = Array.from({ length: count }, (_, i) => ({ id: `lazy-${i}`, title: `Conversation ${i}`, model: "openai/gpt-4o", - workspaceId: "default", + workspaceId: i % workspaceCount === 0 ? "default" : `workspace-${i % workspaceCount}`, status: i === 1 ? "active" : "idle", lastActivityAt: 1234, })); @@ -2763,7 +2764,7 @@ describe("conversation load-on-demand", () => { fetchImpl.mock.calls .map(([url]) => new URL(String(url)).pathname + new URL(String(url)).search) .filter((url) => /\/conversations\/[^/?]+(?:\?sinceSeq=|\/metrics$)/.test(url)); - await vi.waitFor(() => expect(store.tabs).toHaveLength(count)); + await vi.waitFor(() => expect(store.tabs).toHaveLength(Math.ceil(count / workspaceCount))); if (activeConversationId !== null) { await vi.waitFor(() => expect(requests()).toContain(`/conversations/${activeConversationId}/metrics`), @@ -2785,7 +2786,7 @@ describe("conversation load-on-demand", () => { ]); expect( parseSent(ws).filter((m) => (m as { type: string }).type === "chat.subscribe"), - ).toHaveLength(300); + ).toHaveLength(1); expect(store.tabs[299]?.title).toBe("Conversation 299"); expect(store.conversationStatus("lazy-1")).toBe("active"); ws.feedServerMessage({ @@ -2801,6 +2802,24 @@ describe("conversation load-on-demand", () => { } }, 20_000); + it("discovers 339 conversations across 24 workspaces without subscribing to all of them", async () => { + const ws = fakeSocket(); + const { store } = await setup(ws, 339, "lazy-0", 24); + try { + ws.resolveOpen(); + const subscriptions = parseSent(ws).filter( + (message) => (message as { type: string }).type === "chat.subscribe", + ); + expect(subscriptions).toEqual([{ type: "chat.subscribe", conversationId: "lazy-0" }]); + store.setActiveWorkspace("workspace-1"); + store.selectTab("lazy-1"); + expect(parseSent(ws)).toContainEqual({ type: "chat.unsubscribe", conversationId: "lazy-0" }); + expect(parseSent(ws)).toContainEqual({ type: "chat.subscribe", conversationId: "lazy-1" }); + } finally { + store.dispose(); + } + }, 20_000); + it("does not load any history while the active slot is a draft", async () => { const { store, requests } = await setup(fakeSocket(), 300, null); try { @@ -2813,13 +2832,13 @@ describe("conversation load-on-demand", () => { } }, 20_000); - it("loads a tab once, including repeated activation while its load is pending", async () => { + it("loads a tab once while pending and catches up a previously loaded tab on reactivation", async () => { const { store, requests } = await setup(fakeSocket()); try { store.selectTab("lazy-2"); store.selectTab("lazy-0"); store.selectTab("lazy-2"); - await vi.waitFor(() => expect(requests()).toHaveLength(4)); + await vi.waitFor(() => expect(requests()).toHaveLength(6)); expect(requests().filter((u) => u.includes("lazy-2"))).toHaveLength(2); expect(store.activeChat.cacheTimer.running?.startedAt).toBe(1234); } finally { @@ -2827,7 +2846,7 @@ describe("conversation load-on-demand", () => { } }); - it("re-subscribes all tabs on reconnect but resyncs only loaded tabs and watches", async () => { + it("re-subscribes and resyncs only focused tabs and watches on reconnect", async () => { const ws = reconnectableSocket(); const { store, requests } = await setup(ws); try { @@ -2847,7 +2866,7 @@ describe("conversation load-on-demand", () => { ).toBe(true); expect( parseSent(ws).filter((m) => (m as { type: string }).type === "chat.subscribe"), - ).toHaveLength(4); + ).toHaveLength(2); } finally { store.dispose(); } @@ -3129,3 +3148,53 @@ it("routes omitted replay only to the matching watch/tab without WS resend or an store.dispose(); } }); + +describe("unconfirmed first-message recovery", () => { + it("retains a local-only tab and input after reload and authoritative discovery, without resending", async () => { + const storage = createFakeStorage(); + const base = fakeFetchImpl(); + const fetchImpl: typeof fetch = async (input, init) => { + if (String(input).endsWith("/conversations?status=active,idle")) + return Response.json({ conversations: [] }); + return base(input, init); + }; + const ws = fakeSocket(); + const first = createAppStore({ + localStorage: storage, + indexedDB: new IDBFactory(), + fetchImpl, + socketFactory: () => ws, + }); + ws.resolveOpen(); + first.send("must survive", [{ url: "data:image/png;base64,AAAA" }]); + const id = first.activeConversationId; + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(first.activeChat.pendingInputs).toHaveLength(1); + first.dispose(); + const nextSocket = fakeSocket(); + const next = createAppStore({ + localStorage: storage, + indexedDB: new IDBFactory(), + fetchImpl, + socketFactory: () => nextSocket, + }); + try { + nextSocket.resolveOpen(); + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(next.tabs.some((tab) => tab.conversationId === id)).toBe(true); + expect(next.activeChat.pendingInputs).toMatchObject([ + { request: { message: "must survive" } }, + ]); + const requestId = next.activeChat.pendingInputs[0]?.request.requestId ?? ""; + nextSocket.feedServerMessage({ type: "chat.accepted", conversationId: id ?? "", requestId }); + expect(next.activeChat.pendingInputs).toEqual([]); + expect( + parseSent(nextSocket).some((message) => + ["chat.send", "chat.queue"].includes((message as { type: string }).type), + ), + ).toBe(false); + } finally { + next.dispose(); + } + }); +}); diff --git a/src/app/subscription-budget.test.ts b/src/app/subscription-budget.test.ts new file mode 100644 index 0000000..03cdaa1 --- /dev/null +++ b/src/app/subscription-budget.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { + liveConversationIds, + MAX_CHAT_SUBSCRIPTIONS, + MAX_SURFACE_SUBSCRIPTIONS, +} from "./subscription-budget"; + +describe("live subscription budget", () => { + it("reserves the focused conversation even when hundreds of viewers exist", () => { + const viewers = Array.from({ length: 339 }, (_, i) => `viewer-${i}`); + const selected = liveConversationIds("focused", viewers); + expect(selected.size).toBe(MAX_CHAT_SUBSCRIPTIONS); + expect(selected.has("focused")).toBe(true); + expect(selected.size + MAX_SURFACE_SUBSCRIPTIONS).toBeLessThanOrEqual(256); + }); + it("deduplicates a modal viewing the focused chat and permits drafts", () => { + expect([...liveConversationIds("same", ["same", "other"])]).toEqual(["same", "other"]); + expect([...liveConversationIds(null, [])]).toEqual([]); + }); +}); diff --git a/src/app/subscription-budget.ts b/src/app/subscription-budget.ts new file mode 100644 index 0000000..fc0ccb2 --- /dev/null +++ b/src/app/subscription-budget.ts @@ -0,0 +1,15 @@ +/** Split the server's 256 shared slots so catalog growth cannot evict chat watchers. */ +export const MAX_CHAT_SUBSCRIPTIONS = 128; +export const MAX_SURFACE_SUBSCRIPTIONS = 128; + +export function liveConversationIds( + activeId: string | null, + watchedIds: Iterable, +): Set { + return new Set( + [...new Set([...(activeId === null ? [] : [activeId]), ...watchedIds])].slice( + 0, + MAX_CHAT_SUBSCRIPTIONS, + ), + ); +} diff --git a/src/app/surface-protocol.svelte.ts b/src/app/surface-protocol.svelte.ts index 400f612..c79c8b6 100644 --- a/src/app/surface-protocol.svelte.ts +++ b/src/app/surface-protocol.svelte.ts @@ -9,6 +9,7 @@ import { subscribe as protocolSubscribe, unsubscribe as protocolUnsubscribe, } from "../core/protocol"; +import { MAX_SURFACE_SUBSCRIPTIONS } from "./subscription-budget"; /** Shared app/plugin surface operations over the single app protocol state. */ export function createSurfaceProtocol(deps: { @@ -43,7 +44,19 @@ export function createSurfaceProtocol(deps: { */ function syncSubscriptions(): void { const cid = deps.focusedConversationId(); - for (const entry of protocol.catalog) { + const catalogIds = new Set( + protocol.catalog.slice(0, MAX_SURFACE_SUBSCRIPTIONS).map((e) => e.id), + ); + for (const id of [...protocol.subscriptions.keys()]) { + if (!catalogIds.has(id)) { + const result = protocolUnsubscribe(protocol, id); + protocol = result.state; + for (const msg of result.outgoing) { + deps.send(msg); + } + } + } + for (const entry of protocol.catalog.slice(0, MAX_SURFACE_SUBSCRIPTIONS)) { // A GLOBAL surface ignores conversation scope — subscribe it WITHOUT an id // so a conversation switch doesn't churn a redundant unsubscribe+subscribe // round trip (ui-contract@0.2.0 catalog `scope`; ABSENT = assume @@ -55,16 +68,6 @@ export function createSurfaceProtocol(deps: { deps.send(msg); } } - const catalogIds = new Set(protocol.catalog.map((e) => e.id)); - for (const id of [...protocol.subscriptions.keys()]) { - if (!catalogIds.has(id)) { - const result = protocolUnsubscribe(protocol, id); - protocol = result.state; - for (const msg of result.outgoing) { - deps.send(msg); - } - } - } receivedScopes = new Map([...receivedScopes].filter(([id]) => protocol.subscriptions.has(id))); } @@ -93,6 +96,11 @@ export function createSurfaceProtocol(deps: { } function subscribeSurfaceOp(surfaceId: string, conversationId?: string): void { + if ( + !protocol.subscriptions.has(surfaceId) && + protocol.subscriptions.size >= MAX_SURFACE_SUBSCRIPTIONS + ) + return; const result = protocolSubscribe( protocol, surfaceId, diff --git a/src/app/surface-protocol.test.ts b/src/app/surface-protocol.test.ts index 5ac4f70..405e561 100644 --- a/src/app/surface-protocol.test.ts +++ b/src/app/surface-protocol.test.ts @@ -63,3 +63,34 @@ describe("conversation-scoped surface lookup", () => { expect(surfaces.surfaceSpecOp("s1", "c1")).toBeNull(); }); }); + +describe("surface subscription budget", () => { + it("bounds a large catalog and releases old slots before subscribing a replacement catalog", () => { + const live = new Set(); + let peak = 0; + const surfaces = createSurfaceProtocol({ + focusedConversationId: () => "chat", + send(message) { + if (message.type === "subscribe") live.add(message.surfaceId); + if (message.type === "unsubscribe") live.delete(message.surfaceId); + peak = Math.max(peak, live.size); + }, + }); + for (const prefix of ["old", "new"]) { + surfaces.handleServerMessage({ + type: "catalog", + catalog: Array.from({ length: 339 }, (_, i) => ({ + id: `${prefix}-${i}`, + region: "inline", + title: "Surface", + scope: "global" as const, + })), + }); + expect(live.size).toBe(128); + expect([...live].every((id) => id.startsWith(prefix))).toBe(true); + } + surfaces.subscribeSurfaceOp("overflow"); + expect(live.has("overflow")).toBe(false); + expect(peak).toBe(128); + }); +}); diff --git a/src/app/views/ChatPane.svelte b/src/app/views/ChatPane.svelte index 62b9344..d132fad 100644 --- a/src/app/views/ChatPane.svelte +++ b/src/app/views/ChatPane.svelte @@ -7,7 +7,7 @@ * preservation, the ScrollToBottom affordance, and the empty-state * watermark. Keyed on the active conversation exactly like today's ChatView. */ - import { ChatView, isClaudeSubscriptionModel } from "../../features/chat"; + import { ChatView, PendingInputs, isClaudeSubscriptionModel } from "../../features/chat"; import { createSmartScrollController, ScrollToBottom } from "../../features/smart-scroll"; import { preserveViewportAcross, type AppViewPorts } from "../view-ports"; @@ -91,9 +91,10 @@ {cacheTimer} /> {/key} + - {#if store.activeChat.chunks.length === 0} + {#if store.activeChat.chunks.length === 0 && store.activeChat.pendingInputs.length === 0}