summaryrefslogtreecommitdiffhomepage
path: root/src
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-10-21 19:39:47 +0900
committerAdam Malczewski <[email protected]>2026-10-21 19:39:47 +0900
commit7b7d6d01cf94ecad1dcaaa4b87670d50fe15790a (patch)
tree29bbfa52183b2a6639d96ad21d66806fb8ccea30 /src
parentac43e92d174ed1f081c387944e9044643ee9efc2 (diff)
downloaddispatch-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.ts14
-rw-r--r--src/adapters/ws/logic.ts26
-rw-r--r--src/app/App.test.ts1
-rw-r--r--src/app/chat-registry.svelte.ts59
-rw-r--r--src/app/open-conversations.svelte.ts16
-rw-r--r--src/app/store.test.ts87
-rw-r--r--src/app/subscription-budget.test.ts20
-rw-r--r--src/app/subscription-budget.ts15
-rw-r--r--src/app/surface-protocol.svelte.ts30
-rw-r--r--src/app/surface-protocol.test.ts31
-rw-r--r--src/app/views/ChatPane.svelte5
-rw-r--r--src/app/ws-handlers.ts37
-rw-r--r--src/core/wire/conformance.test.ts8
-rw-r--r--src/core/wire/conformance.ts4
-rw-r--r--src/features/chat/adapter/pending-inputs.ts49
-rw-r--r--src/features/chat/delivery.test.ts146
-rw-r--r--src/features/chat/index.ts3
-rw-r--r--src/features/chat/pending-inputs.ts12
-rw-r--r--src/features/chat/store.svelte.ts104
-rw-r--r--src/features/chat/store.test.ts1
-rw-r--r--src/features/chat/ui/PendingInputs.svelte34
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}