diff options
| author | Adam Malczewski <[email protected]> | 2026-10-21 19:39:47 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-10-21 19:39:47 +0900 |
| commit | 7b7d6d01cf94ecad1dcaaa4b87670d50fe15790a (patch) | |
| tree | 29bbfa52183b2a6639d96ad21d66806fb8ccea30 /src | |
| parent | ac43e92d174ed1f081c387944e9044643ee9efc2 (diff) | |
| download | dispatch-web-7b7d6d01cf94ecad1dcaaa4b87670d50fe15790a.tar.gz dispatch-web-7b7d6d01cf94ecad1dcaaa4b87670d50fe15790a.zip | |
fix(chat): bound live subscriptions and preserve unconfirmed input
Diffstat (limited to 'src')
| -rw-r--r-- | src/adapters/ws/index.ts | 14 | ||||
| -rw-r--r-- | src/adapters/ws/logic.ts | 26 | ||||
| -rw-r--r-- | src/app/App.test.ts | 1 | ||||
| -rw-r--r-- | src/app/chat-registry.svelte.ts | 59 | ||||
| -rw-r--r-- | src/app/open-conversations.svelte.ts | 16 | ||||
| -rw-r--r-- | src/app/store.test.ts | 87 | ||||
| -rw-r--r-- | src/app/subscription-budget.test.ts | 20 | ||||
| -rw-r--r-- | src/app/subscription-budget.ts | 15 | ||||
| -rw-r--r-- | src/app/surface-protocol.svelte.ts | 30 | ||||
| -rw-r--r-- | src/app/surface-protocol.test.ts | 31 | ||||
| -rw-r--r-- | src/app/views/ChatPane.svelte | 5 | ||||
| -rw-r--r-- | src/app/ws-handlers.ts | 37 | ||||
| -rw-r--r-- | src/core/wire/conformance.test.ts | 8 | ||||
| -rw-r--r-- | src/core/wire/conformance.ts | 4 | ||||
| -rw-r--r-- | src/features/chat/adapter/pending-inputs.ts | 49 | ||||
| -rw-r--r-- | src/features/chat/delivery.test.ts | 146 | ||||
| -rw-r--r-- | src/features/chat/index.ts | 3 | ||||
| -rw-r--r-- | src/features/chat/pending-inputs.ts | 12 | ||||
| -rw-r--r-- | src/features/chat/store.svelte.ts | 104 | ||||
| -rw-r--r-- | src/features/chat/store.test.ts | 1 | ||||
| -rw-r--r-- | src/features/chat/ui/PendingInputs.svelte | 34 |
21 files changed, 636 insertions, 66 deletions
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<string, ChatStore>(); // Includes in-flight loads: repeated activation must not start a second load. const loadedConversationIds = new Set<string>(); + const loadingConversationIds = new Set<string>(); // 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<string, ChatStore>(); + const watchedIds = new Set<string>(); + const subscribedIds = new Set<string>(); + + 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<void> { + let pendingFetch: Promise<void> | undefined; + function fetchOpenConversations(): Promise<void> { + pendingFetch ??= loadOpenConversations().finally(() => { + pendingFetch = undefined; + }); + return pendingFetch; + } + + async function loadOpenConversations(): Promise<void> { 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<string>, +): Set<string> { + 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 ([email protected] 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<string>(); + 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} + <PendingInputs inputs={store.activeChat.pendingInputs} onDiscard={store.activeChat.discardPendingInput} /> </div> </div> - {#if store.activeChat.chunks.length === 0} + {#if store.activeChat.chunks.length === 0 && store.activeChat.pendingInputs.length === 0} <div class="pointer-events-none absolute inset-0 flex items-center justify-center" aria-hidden="true" diff --git a/src/app/ws-handlers.ts b/src/app/ws-handlers.ts index b9adb30..e4fd916 100644 --- a/src/app/ws-handlers.ts +++ b/src/app/ws-handlers.ts @@ -1,8 +1,10 @@ import type { + ChatAcceptedMessage, ChatDeltaMessage, ChatErrorMessage, ChatQueuedMessage, ChatResyncRequiredMessage, + ChatSubscriptionRejectedMessage, ConversationCompactedMessage, ConversationOpenMessage, ConversationStatusChangedMessage, @@ -55,7 +57,13 @@ export function createWsHandlers(deps: { tileLayoutStore, } = deps; function handleChatMessage( - msg: ChatDeltaMessage | ChatErrorMessage | ChatQueuedMessage | ChatResyncRequiredMessage, + msg: + | ChatAcceptedMessage + | ChatSubscriptionRejectedMessage + | ChatDeltaMessage + | ChatErrorMessage + | ChatQueuedMessage + | ChatResyncRequiredMessage, ): void { let targetId: string | undefined; if (msg.type === "chat.delta") { @@ -72,7 +80,13 @@ export function createWsHandlers(deps: { } } - if (msg.type === "chat.queued" || msg.type === "chat.resync-required") return; + if ( + msg.type === "chat.accepted" || + msg.type === "chat.subscription-rejected" || + msg.type === "chat.queued" || + msg.type === "chat.resync-required" + ) + return; // fallback: try all stores (chat.error without conversationId) for (const store of registry.chatStores.values()) { @@ -166,23 +180,8 @@ export function createWsHandlers(deps: { // workspace too. Re-declare it so the re-pushed catalog is filtered // (plan 06 §6.4). deps.send(deps.workspaceSetMsg(deps.activeWorkspaceId())); - // Re-attach to every open conversation's turn stream. A turn that kept - // running while we were disconnected resumes streaming (server replays it - // from `turn-start`); one that sealed while we were gone is committed from - // history by `resync()` (which also clears a now-stale "generating"). - // Unloaded tabs stay lazy — only their cheap live subscription is restored. - for (const tab of tabsStore.tabs) { - registry.subscribeChat(tab.conversationId); - if (registry.loadedConversationIds.has(tab.conversationId)) { - registry.chatStores.get(tab.conversationId)?.resync(); - } - } - // Re-attach to every MODAL watch too (a run-chat modal open across a - // reconnect keeps streaming). Watch stores are separate from tabs. - for (const [watchId, watchStore] of registry.watchStores) { - registry.subscribeChat(watchId); - if (registry.loadedConversationIds.has(watchId)) watchStore.resync(); - } + registry.reconnect(); + void conversations.fetchOpenConversations(); }, onExtensionsChanged(_msg: ExtensionsChangedMessage) { // §11 multi-client coherence: another client (or this one) mutated the diff --git a/src/core/wire/conformance.test.ts b/src/core/wire/conformance.test.ts index b3c774f..6bafda1 100644 --- a/src/core/wire/conformance.test.ts +++ b/src/core/wire/conformance.test.ts @@ -178,6 +178,12 @@ describe("classifies every WsServerMessage type", () => { event: { type: "done" as const, conversationId: "c", turnId: "t", reason: "r" }, }, { type: "chat.error" as const, message: "e" }, + { type: "chat.accepted" as const, conversationId: "c1", requestId: "r1" }, + { + type: "chat.subscription-rejected" as const, + conversationId: "c1", + reason: "subscription-limit" as const, + }, { type: "chat.queued" as const, conversationId: "conv-1", @@ -235,6 +241,8 @@ describe("classifies every WsServerMessage type", () => { "error", "chat.delta", "chat.error", + "chat.accepted", + "chat.subscription-rejected", "chat.queued", "chat.resync-required", "conversation.open", diff --git a/src/core/wire/conformance.ts b/src/core/wire/conformance.ts index 3429ed0..cd53b65 100644 --- a/src/core/wire/conformance.ts +++ b/src/core/wire/conformance.ts @@ -87,6 +87,10 @@ export function assertWsServerMessageExhaustive(msg: WsServerMessage): string { return "chat.delta"; case "chat.resync-required": return "chat.resync-required"; + case "chat.accepted": + return "chat.accepted"; + case "chat.subscription-rejected": + return "chat.subscription-rejected"; case "chat.queued": return "chat.queued"; case "chat.error": diff --git a/src/features/chat/adapter/pending-inputs.ts b/src/features/chat/adapter/pending-inputs.ts new file mode 100644 index 0000000..5798fdb --- /dev/null +++ b/src/features/chat/adapter/pending-inputs.ts @@ -0,0 +1,49 @@ +import type { PendingInput, PendingInputStorage } from "../pending-inputs"; + +export function createPendingInputStorage( + storage: Storage, + conversationId: string, +): PendingInputStorage { + const prefix = `dispatch.pending-inputs:${conversationId}:`; + let known = new Map<string, string>(); + return { + load() { + const inputs: PendingInput[] = []; + const loaded = new Map<string, string>(); + for (let i = 0; i < storage.length; i++) { + const key = storage.key(i); + if (key === null || !key.startsWith(prefix)) continue; + const raw = storage.getItem(key); + if (raw === null) continue; + const input = JSON.parse(raw); + if ( + (input?.request?.type !== "chat.send" && input?.request?.type !== "chat.queue") || + input.request.conversationId !== conversationId || + typeof input.request.requestId !== "string" || + key !== prefix + input.request.requestId || + (input.request.type === "chat.send" + ? typeof input.request.message !== "string" + : typeof input.request.text !== "string") || + (input.rejection !== undefined && typeof input.rejection !== "string") + ) + throw new Error("Invalid saved input"); + inputs.push(input); + loaded.set(input.request.requestId, raw); + } + known = loaded; + return inputs; + }, + save(inputs) { + // Per-request keys prevent another browser tab's send/receipt from overwriting ours. + // Quota/security failures reach the composer BEFORE it clears its staged input. + const next = new Map(inputs.map((input) => [input.request.requestId, JSON.stringify(input)])); + for (const [id, raw] of next) { + if (known.get(id) !== raw) storage.setItem(prefix + id, raw); + } + for (const id of known.keys()) { + if (!next.has(id)) storage.removeItem(prefix + id); + } + known = next; + }, + }; +} diff --git a/src/features/chat/delivery.test.ts b/src/features/chat/delivery.test.ts new file mode 100644 index 0000000..99ad2d4 --- /dev/null +++ b/src/features/chat/delivery.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, it, vi } from "vitest"; +import { createPendingInputStorage } from "./adapter/pending-inputs"; +import { createChatStore } from "./store.svelte"; +import { + createFakeCache, + createFakeHistorySync, + createFakeMetricsSync, + createFakeTransport, +} from "./test-helpers"; + +function storage(): Storage { + const values = new Map<string, string>(); + return { + get length() { + return values.size; + }, + getItem: (key) => values.get(key) ?? null, + setItem: (key, value) => { + values.set(key, value); + }, + removeItem: (key) => { + values.delete(key); + }, + clear: () => values.clear(), + key: (index) => [...values.keys()][index] ?? null, + }; +} + +function fixture(saved = storage(), metricsSync = createFakeMetricsSync().impl) { + const transport = createFakeTransport(); + let id = 0; + const store = createChatStore({ + conversationId: "c", + createRequestId: () => `request-${++id}`, + pendingInputStorage: createPendingInputStorage(saved, "c"), + transport: transport.impl, + historySync: createFakeHistorySync().impl, + metricsSync, + cache: createFakeCache().impl, + }); + return { store, transport, saved }; +} + +const images = [{ url: "data:image/png;base64,AAAA", mimeType: "image/png" }]; + +describe("input delivery", () => { + it.each([ + "send", + "queueMessage", + ] as const)("saves %s input through refresh without automatically retransmitting", (method) => { + const f = fixture(); + f.store[method]("keep me", images); + expect(f.store.pendingInputs).toHaveLength(1); + f.store.dispose(); + const reloaded = fixture(f.saved); + expect(reloaded.store.pendingInputs).toMatchObject([{ request: { images } }]); + expect(JSON.stringify(reloaded.store.pendingInputs)).toContain("keep me"); + expect(reloaded.transport.sent).toEqual([]); + expect(reloaded.transport.sentQueue).toEqual([]); + reloaded.store.dispose(); + }); + + it("streamed user echoes and wrong receipts cannot discard an unconfirmed send", () => { + const f = fixture(); + f.store.send("mine", images); + f.store.handleDelta({ + type: "chat.delta", + event: { type: "user-message", conversationId: "c", turnId: "t", text: "mine" }, + }); + f.store.handleDelta({ type: "chat.accepted", conversationId: "other", requestId: "request-1" }); + f.store.handleDelta({ type: "chat.accepted", conversationId: "c", requestId: "other" }); + expect(f.store.pendingInputs).toHaveLength(1); + f.store.handleDelta({ type: "chat.accepted", conversationId: "c", requestId: "request-1" }); + expect(f.store.pendingInputs).toEqual([]); + expect(createPendingInputStorage(f.saved, "c").load()).toEqual([]); + f.store.dispose(); + }); + + it("retains explicitly rejected input and images for recovery until discarded", () => { + const f = fixture(); + f.store.send("mine", images); + f.store.handleDelta({ + type: "chat.error", + conversationId: "c", + requestId: "request-1", + message: "disk full", + }); + const reloaded = fixture(f.saved); + expect(reloaded.store.pendingInputs).toMatchObject([ + { rejection: "disk full", request: { message: "mine", images } }, + ]); + reloaded.store.discardPendingInput("request-1"); + expect(createPendingInputStorage(f.saved, "c").load()).toEqual([]); + f.store.dispose(); + reloaded.store.dispose(); + }); + + it.each([ + "send", + "queueMessage", + ] as const)("does not clear or transmit %s input if saving fails", (method) => { + const saved = storage(); + saved.setItem = () => { + throw new Error("quota exceeded"); + }; + const f = fixture(saved); + expect(() => f.store[method]("mine", images)).toThrow("quota exceeded"); + expect(f.store.pendingInputs).toEqual([]); + expect(f.store.chunks).toEqual([]); + expect(f.transport.sent).toEqual([]); + expect(f.transport.sentQueue).toEqual([]); + f.store.dispose(); + }); + + it("coalesces a reconnect burst and permits one metrics catch-up after completion", async () => { + let resolve!: (value: { turns: [] }) => void; + const first = { + promise: new Promise<{ turns: [] }>((done) => { + resolve = done; + }), + resolve: (value: { turns: [] }) => resolve(value), + }; + const metricsSync = vi.fn().mockReturnValueOnce(first.promise).mockResolvedValue({ turns: [] }); + const f = fixture(storage(), metricsSync); + for (let i = 0; i < 100; i++) f.store.resync(); + expect(metricsSync).toHaveBeenCalledTimes(1); + first.resolve({ turns: [] }); + await vi.waitFor(() => expect(metricsSync).toHaveBeenCalledTimes(2)); + f.store.dispose(); + }); +}); + +it("concurrent browser tabs cannot overwrite each other's saved messages or receipts", () => { + const saved = storage(); + const first = createPendingInputStorage(saved, "c"); + const second = createPendingInputStorage(saved, "c"); + first.load(); + second.load(); + const input = (requestId: string) => ({ + request: { type: "chat.send" as const, conversationId: "c", requestId, message: requestId }, + }); + first.save([input("first")]); + second.save([input("second")]); + first.save([]); + expect(createPendingInputStorage(saved, "c").load()).toEqual([input("second")]); +}); diff --git a/src/features/chat/index.ts b/src/features/chat/index.ts index fec2cdb..41cb06f 100644 --- a/src/features/chat/index.ts +++ b/src/features/chat/index.ts @@ -6,6 +6,7 @@ export type { } from "../../core/chunks"; export { groupRenderedChunks, resolveImageUrl, viewProviderRetry } from "../../core/chunks"; export type { TurnMetricsEntry } from "../../core/metrics"; +export { createPendingInputStorage } from "./adapter/pending-inputs"; export type { NamedAgentLike, NamedAgentSnapshotLike } from "./model-select"; export { CLAUDE_SUBSCRIPTION_KEY, @@ -22,6 +23,7 @@ export { openCodeNumber, providerKeysWithNamedAgents, } from "./model-select"; +export type { PendingInput, PendingInputStorage } from "./pending-inputs"; export type { ChatTransport, HistorySync, HistoryWindow, MetricsSync } from "./ports"; export type { EffortOption, @@ -53,6 +55,7 @@ export { default as CompactionView } from "./ui/CompactionView.svelte"; export type { ComposerStatus } from "./ui/Composer.svelte"; export { default as Composer } from "./ui/Composer.svelte"; export { default as ModelSelector } from "./ui/ModelSelector.svelte"; +export { default as PendingInputs } from "./ui/PendingInputs.svelte"; export { default as ReasoningEffortSelector } from "./ui/ReasoningEffortSelector.svelte"; /** Public module manifest — aggregated by the shell's "Loaded Modules" view. */ diff --git a/src/features/chat/pending-inputs.ts b/src/features/chat/pending-inputs.ts new file mode 100644 index 0000000..d7fb0aa --- /dev/null +++ b/src/features/chat/pending-inputs.ts @@ -0,0 +1,12 @@ +import type { ChatQueueMessage, ChatSendMessage } from "@dispatch/transport-contract"; + +/** A saved copy, never automatically retransmitted: requestId is not idempotent. */ +export interface PendingInput { + readonly request: (ChatSendMessage | ChatQueueMessage) & { readonly requestId: string }; + readonly rejection?: string; +} + +export interface PendingInputStorage { + load(): readonly PendingInput[]; + save(inputs: readonly PendingInput[]): void; +} diff --git a/src/features/chat/store.svelte.ts b/src/features/chat/store.svelte.ts index bb0b218..dacbe06 100644 --- a/src/features/chat/store.svelte.ts +++ b/src/features/chat/store.svelte.ts @@ -1,4 +1,5 @@ import type { + ChatAcceptedMessage, ChatDeltaMessage, ChatErrorMessage, ChatQueueCancelMessage, @@ -6,6 +7,7 @@ import type { ChatQueueMessage, ChatResyncRequiredMessage, ChatSendMessage, + ChatSubscriptionRejectedMessage, } from "@dispatch/transport-contract"; import type { ChatMessage, ImageInput, StoredChunk, TurnProviderRetryEvent } from "@dispatch/wire"; import type { RenderedChunk, TranscriptState } from "../../core/chunks"; @@ -43,9 +45,11 @@ import { } from "../../core/metrics"; import type { ConversationCache } from "../conversation-cache"; import type { NamedAgentSelectionSnapshot } from "../named-agents"; +import type { PendingInput, PendingInputStorage } from "./pending-inputs"; import type { ChatTransport, HistorySync, MetricsSync } from "./ports"; export interface ChatStoreDependencies { + readonly pendingInputStorage?: PendingInputStorage; readonly conversationId: string; /** Injected unique client-local correlation token for each new send. */ readonly createRequestId: () => string; @@ -98,6 +102,8 @@ export interface ChatStoreDependencies { } export interface ChatStore { + readonly pendingInputs: readonly PendingInput[]; + discardPendingInput(requestId: string): void; readonly messages: readonly ChatMessage[]; readonly chunks: readonly RenderedChunk[]; readonly turnMetrics: readonly TurnMetricsEntry[]; @@ -156,7 +162,13 @@ export interface ChatStore { */ noteLastActivity(at: number): void; handleDelta( - msg: ChatDeltaMessage | ChatErrorMessage | ChatQueuedMessage | ChatResyncRequiredMessage, + msg: + | ChatAcceptedMessage + | ChatSubscriptionRejectedMessage + | ChatDeltaMessage + | ChatErrorMessage + | ChatQueuedMessage + | ChatResyncRequiredMessage, ): void; /** * Send a user message (start a turn via `chat.send`). Optimistically echoes @@ -232,6 +244,14 @@ export interface ChatStore { } export function createChatStore(deps: ChatStoreDependencies): ChatStore { + let pendingInputs = $state<readonly PendingInput[]>(deps.pendingInputStorage?.load() ?? []); + function savePendingInputs(next: readonly PendingInput[]): void { + deps.pendingInputStorage?.save(next); + pendingInputs = next; + } + function confirmInput(requestId: string): void { + savePendingInputs(pendingInputs.filter((input) => input.request.requestId !== requestId)); + } let transcript = $state<TranscriptState>(initialState()); let metrics = $state<MetricsState>(initialMetricsState()); let _pendingSync = $state(false); @@ -507,10 +527,30 @@ export function createChatStore(deps: ChatStoreDependencies): ChatStore { } } - async function syncMetrics(): Promise<void> { + let pendingMetrics: Promise<void> | undefined; + let rerunMetrics = false; + function syncMetrics(): Promise<void> { + if (pendingMetrics !== undefined) { + rerunMetrics = true; + return pendingMetrics; + } + pendingMetrics = (async () => { + for (let attempt = 0; attempt < 2; attempt++) { + rerunMetrics = false; + await fetchMetrics(); + if (!rerunMetrics || disposed) break; + } + })().finally(() => { + pendingMetrics = undefined; + }); + return pendingMetrics; + } + + async function fetchMetrics(): Promise<void> { if (disposed) return; try { const res = await deps.metricsSync(deps.conversationId); + if (disposed) return; metrics = applyDurableMetrics(metrics, res.turns); } catch (err) { // Metrics fetch failure must not block history sync or throw; @@ -580,6 +620,10 @@ export function createChatStore(deps: ChatStoreDependencies): ChatStore { } return { + get pendingInputs() { + return pendingInputs; + }, + discardPendingInput: confirmInput, get messages(): readonly ChatMessage[] { return selectMessages(transcript); }, @@ -626,9 +670,27 @@ export function createChatStore(deps: ChatStoreDependencies): ChatStore { }, handleDelta( - msg: ChatDeltaMessage | ChatErrorMessage | ChatQueuedMessage | ChatResyncRequiredMessage, + msg: + | ChatAcceptedMessage + | ChatSubscriptionRejectedMessage + | ChatDeltaMessage + | ChatErrorMessage + | ChatQueuedMessage + | ChatResyncRequiredMessage, ): void { if (disposed) return; + if (msg.type === "chat.accepted") { + if (msg.conversationId === deps.conversationId) confirmInput(msg.requestId); + return; + } + if (msg.type === "chat.subscription-rejected") { + if (msg.conversationId === deps.conversationId) { + _error = + "Live updates could not be subscribed. Message delivery is independent; saved inputs remain available below."; + void syncTail(); + } + return; + } if (msg.type === "chat.resync-required") { if (msg.conversationId !== deps.conversationId) return; flushEvents(); @@ -656,6 +718,16 @@ export function createChatStore(deps: ChatStoreDependencies): ChatStore { return; } _error = msg.message; + if (msg.requestId !== undefined) { + savePendingInputs( + pendingInputs.map((input) => + input.request.requestId === msg.requestId + ? { ...input, rejection: msg.message } + : input, + ), + ); + transcript = removeLocalEcho(transcript, msg.requestId); + } return; } if (msg.type === "chat.queued") { @@ -663,7 +735,10 @@ export function createChatStore(deps: ChatStoreDependencies): ChatStore { // A queued live opening may already have confirmed the echoed text. // Fold it first; turn-start alone must not protect still-local artifacts. flushEvents(); - if (msg.requestId !== undefined) transcript = removeLocalEcho(transcript, msg.requestId); + if (msg.requestId !== undefined) { + confirmInput(msg.requestId); + transcript = removeLocalEcho(transcript, msg.requestId); + } return; } if (msg.event.conversationId !== deps.conversationId) { @@ -685,9 +760,7 @@ export function createChatStore(deps: ChatStoreDependencies): ChatStore { // Sending ends the wait since the last turn: stamp it on that turn. cacheTimer = stopTurnGap(cacheTimer, now(), () => lastTurnIdOf(metrics)); const requestId = deps.createRequestId(); - transcript = appendUserMessage(transcript, text, images, requestId); - maybeTrim(); - const msg: ChatSendMessage = { + const msg: ChatSendMessage & { requestId: string } = { type: "chat.send", requestId, conversationId: deps.conversationId, @@ -697,19 +770,34 @@ export function createChatStore(deps: ChatStoreDependencies): ChatStore { ...(deps.workspaceId !== undefined ? { workspaceId: deps.workspaceId } : {}), ...(images !== undefined && images.length > 0 ? { images: [...images] } : {}), }; + try { + savePendingInputs([...pendingInputs, { request: msg }]); + } catch (error) { + deps.onError?.("Unable to save input; the message has not been sent", error); + throw error; + } + transcript = appendUserMessage(transcript, text, images, requestId); + maybeTrim(); deps.transport.send(msg); }, queueMessage(text: string, images?: readonly ImageInput[]): void { const trimmed = text.trim(); if (trimmed.length === 0 && !images?.length) return; - const msg: ChatQueueMessage = { + const msg: ChatQueueMessage & { requestId: string } = { type: "chat.queue", + requestId: deps.createRequestId(), conversationId: deps.conversationId, text: trimmed, ...(images !== undefined && images.length > 0 ? { images: [...images] } : {}), ...(deps.workspaceId !== undefined ? { workspaceId: deps.workspaceId } : {}), }; + try { + savePendingInputs([...pendingInputs, { request: msg }]); + } catch (error) { + deps.onError?.("Unable to save input; the message has not been queued", error); + throw error; + } deps.transport.send(msg); }, diff --git a/src/features/chat/store.test.ts b/src/features/chat/store.test.ts index c24592d..5040e67 100644 --- a/src/features/chat/store.test.ts +++ b/src/features/chat/store.test.ts @@ -290,6 +290,7 @@ describe("createChatStore", () => { expect(transport.sentQueue).toEqual([ { type: "chat.queue", + requestId: expect.any(String), conversationId: CONV_ID, workspaceId: "images-workspace", text: text.trim(), diff --git a/src/features/chat/ui/PendingInputs.svelte b/src/features/chat/ui/PendingInputs.svelte new file mode 100644 index 0000000..cbaf35f --- /dev/null +++ b/src/features/chat/ui/PendingInputs.svelte @@ -0,0 +1,34 @@ +<script lang="ts"> + import type { PendingInput } from "../pending-inputs"; + + let { + inputs, + onDiscard, + }: { + inputs: readonly PendingInput[]; + onDiscard: (requestId: string) => void; + } = $props(); +</script> + +{#each inputs as input (input.request.requestId)} + <div class="m-3 rounded border border-warning p-3 text-sm" role="status"> + <p> + {input.rejection + ? `Message rejected: ${input.rejection}` + : "Delivery unconfirmed. Your input is saved on this device."} + </p> + <p>Check the conversation history before sending again to avoid a duplicate.</p> + <textarea + class="textarea textarea-bordered mt-2 w-full" + aria-label="Saved message" + readonly + value={input.request.type === "chat.send" ? input.request.message : input.request.text} + ></textarea> + {#each input.request.images ?? [] as image} + <img class="my-2 max-h-32" src={image.url} alt="Saved attachment" /> + {/each} + <button class="btn btn-sm mt-2" onclick={() => onDiscard(input.request.requestId)} + >Discard saved copy</button + > + </div> +{/each} |
