summaryrefslogtreecommitdiffhomepage
path: root/src/app
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-21 02:19:54 +0900
committerAdam Malczewski <[email protected]>2026-06-21 02:19:54 +0900
commitd98a63ce17519983dcf58c27432723e2f4b96e75 (patch)
tree21a4e043d040984aa62fd2ba81ca3349ce01f5c4 /src/app
parent9c90105b6cfede0f3327169718300c649bb0531a (diff)
downloaddispatch-web-d98a63ce17519983dcf58c27432723e2f4b96e75.tar.gz
dispatch-web-d98a63ce17519983dcf58c27432723e2f4b96e75.zip
feat(chat): message queue + steering — mid-turn injection at tool-result boundaries
Consume the message-queue + steering handoff ([email protected], [email protected]). Re-pinned file: deps + re-mirrored .dispatch/*.reference.md. - fold steering AgentEvent into the transcript as a provisional user bubble (after the tool-result it followed; no de-dup — the queue surface carried it) - add rendererId: "message-queue" custom renderer (pure parser + MessageQueueList) rendered as a compact panel above the Composer (hidden when queue is empty) - add ChatStore.queueMessage / AppStore.queueMessage — sends chat.queue WS op (trim/validate non-empty; auto-starts a turn if idle) - Composer switches to chat.queue while generating (button → Queue, placeholder → Steer the conversation...) - exhaustiveness guards updated for steering + chat.queue - carry-to-new-turn needs no special handling (normal new turn) 664 tests green.
Diffstat (limited to 'src/app')
-rw-r--r--src/app/App.svelte69
-rw-r--r--src/app/store.svelte.ts69
2 files changed, 130 insertions, 8 deletions
diff --git a/src/app/App.svelte b/src/app/App.svelte
index dffa937..ee72ca5 100644
--- a/src/app/App.svelte
+++ b/src/app/App.svelte
@@ -19,11 +19,17 @@
import { manifest as conversationCacheManifest } from "../features/conversation-cache";
import { manifest as markdownManifest } from "../features/markdown";
import {
+ ChatLimitField,
+ manifest as settingsManifest,
+ type ChatLimitSaveResult,
+ } from "../features/settings";
+ import {
createSmartScrollController,
manifest as smartScrollManifest,
ScrollToBottom,
} from "../features/smart-scroll";
import { manifest as surfaceHostManifest, SurfaceView } from "../features/surface-host";
+ import { parseMessageQueuePayload } from "../features/surface-host/logic/message-queue";
import { manifest as tabsManifest, TabBar } from "../features/tabs";
import { manifest as viewsManifest, ViewSidebar } from "../features/views";
import {
@@ -42,6 +48,10 @@
// and keep it out of the generic Extensions surface list — SurfaceView itself
// stays fully generic (it never switches on a surface id).
const CACHE_WARMING_ID = "cache-warming";
+ // The message-queue extension's per-conversation surface (steering). Pulled
+ // out of the generic Extensions list and rendered as a compact panel above the
+ // composer — pending steering messages are tied to the chat, not the sidebar.
+ const MESSAGE_QUEUE_ID = "message-queue";
// The view kinds offered in the sidebar's dropdown. Generic data — the
// `viewContent` snippet below maps each kind id to its renderer.
@@ -50,10 +60,11 @@
{ id: "lsp", label: "Language Servers" },
{ id: "extensions", label: "Extensions" },
{ id: "cache-warming", label: "Cache Warming" },
+ { id: "settings", label: "Settings" },
] as const;
- // Default sidebar layout: Model panel on top, then Language Servers, Extensions, Cache Warming.
- const initialViews = ["model", "lsp", "extensions", "cache-warming"] as const;
+ // Default sidebar layout: Model, Language Servers, Extensions, Cache Warming, Settings.
+ const initialViews = ["model", "lsp", "extensions", "cache-warming", "settings"] as const;
// Frontend module list for the "Loaded Modules" view, AGGREGATED from each
// feature's public `manifest` export so it can't drift from what's actually
@@ -71,6 +82,7 @@
cacheWarmingManifest,
workspaceManifest,
smartScrollManifest,
+ settingsManifest,
].map((m) => [m.name, m.description] as const);
// Smart-scroll: keep the transcript pinned to the bottom while it streams,
@@ -120,6 +132,18 @@
smartScroll.contentChanged();
});
+ // The message-queue surface spec + whether it currently has pending messages
+ // (steering). Rendered as a compact panel above the composer only when non-empty.
+ const messageQueueSpec = $derived(store.surface(MESSAGE_QUEUE_ID));
+ const hasQueuedMessages = $derived.by(() => {
+ const spec = messageQueueSpec;
+ if (spec === null) return false;
+ const field = spec.fields.find((f) => f.kind === "custom" && f.rendererId === MESSAGE_QUEUE_ID);
+ if (field === undefined || field.kind !== "custom") return false;
+ const data = parseMessageQueuePayload(field.payload);
+ return data !== null && data.messages.length > 0;
+ });
+
// Conversation/tab switch → snap to the bottom of the new transcript.
$effect(() => {
void store.activeConversationId;
@@ -140,6 +164,10 @@
store.send(text);
}
+ function handleQueue(text: string) {
+ store.queueMessage(text);
+ }
+
function handleSelectModel(model: string) {
store.selectModel(model);
}
@@ -168,6 +196,25 @@
: { ok: false, error: result.error };
}
+ // Adapt the store's chat-limit result to the settings feature's port. On a
+ // raise the active chat refills (prepends older history); preserve the
+ // reader's viewport over the prepend (the manual analogue of CSS scroll
+ // anchoring), exactly like `handleShowEarlier`.
+ async function saveChatLimit(value: number): Promise<ChatLimitSaveResult> {
+ const el = transcriptEl;
+ const prevHeight = el?.scrollHeight ?? 0;
+ const prevTop = el?.scrollTop ?? 0;
+ const result = await store.setChatLimit(value);
+ await tick();
+ if (el) {
+ const delta = el.scrollHeight - prevHeight;
+ if (delta > 0) el.scrollTop = prevTop + delta;
+ }
+ return result.ok
+ ? { ok: true, chatLimit: result.chatLimit }
+ : { ok: false, error: result.error };
+ }
+
// Adapt the store's cwd/LSP results to the workspace feature's ports.
async function saveCwd(cwd: string): Promise<CwdSaveResult | null> {
const result = await store.setCwd(cwd);
@@ -262,8 +309,18 @@
<ScrollToBottom show={smartScroll.showButton} onResume={() => smartScroll.resume()} />
</div>
+ {#if hasQueuedMessages && messageQueueSpec !== null}
+ <!-- Pending steering messages (the message-queue surface). Rendered via
+ the generic SurfaceView (dispatches on rendererId, never surface id);
+ only shown when the queue is non-empty — an idle queue is hidden. -->
+ <div class="px-4 pt-2">
+ <SurfaceView spec={messageQueueSpec} onInvoke={handleInvoke} />
+ </div>
+ {/if}
+
<Composer
onSend={handleSend}
+ onQueue={handleQueue}
contextSize={store.activeChat.currentContextSize}
status={store.activeChat.error
? "error"
@@ -329,7 +386,7 @@
</section>
<section class="mt-4 flex flex-col gap-3">
<h3 class="text-xs font-semibold uppercase opacity-60">Surfaces</h3>
- {#each store.surfaces.filter((s) => s.id !== CACHE_WARMING_ID) as spec (spec.id)}
+ {#each store.surfaces.filter((s) => s.id !== CACHE_WARMING_ID && s.id !== MESSAGE_QUEUE_ID) as spec (spec.id)}
<SurfaceView {spec} onInvoke={handleInvoke} />
{/each}
</section>
@@ -344,5 +401,11 @@
{warmNow}
/>
{/key}
+ {:else if kind === "settings"}
+ <!-- FE-local settings. Not conversation-scoped (no {#key}: the chat limit is
+ global), so the field stays mounted across tab switches. -->
+ <div class="flex flex-col gap-3">
+ <ChatLimitField chatLimit={store.chatLimit} save={saveChatLimit} />
+ </div>
{/if}
{/snippet}
diff --git a/src/app/store.svelte.ts b/src/app/store.svelte.ts
index e8bb5e1..dc06ea1 100644
--- a/src/app/store.svelte.ts
+++ b/src/app/store.svelte.ts
@@ -60,6 +60,11 @@ export type ReasoningEffortResult =
| { readonly ok: true; readonly reasoningEffort: ReasoningEffort }
| { readonly ok: false; readonly error: string };
+/** Outcome of persisting a chat-limit setting (localStorage; FE-local). */
+export type ChatLimitResult =
+ | { readonly ok: true; readonly chatLimit: number }
+ | { readonly ok: false; readonly error: string };
+
export interface AppStore {
readonly tabs: readonly Tab[];
readonly activeConversationId: string | null;
@@ -73,6 +78,14 @@ export interface AppStore {
/** The current spec for one surface by id (discovery-by-id), or null if absent. */
surface(surfaceId: string): SurfaceSpec | null;
send(text: string): void;
+ /**
+ * Enqueue a steering message onto the focused conversation's queue
+ * (`chat.queue` WS op). While a turn is generating, the message is delivered
+ * mid-turn at the next tool-result boundary; when idle, the server
+ * auto-starts a turn (equivalent to `send`). Safe to offer whenever the user
+ * wants to add input — the server owns the idle-vs-generating decision.
+ */
+ queueMessage(text: string): void;
selectModel(model: string): void;
newDraft(): void;
selectTab(conversationId: string): void;
@@ -109,6 +122,16 @@ export interface AppStore {
* The backend lazily spawns servers, so this may take a moment on the first call for a cwd.
*/
lspStatus(): Promise<LspResult | null>;
+ /** The persisted chat limit (max loaded chunks per conversation). */
+ readonly chatLimit: number;
+ /**
+ * Persist + live-apply a new chat limit: writes `dispatch.chatLimit` to
+ * localStorage and propagates to every live chat store (trim if lower,
+ * deferred via the unload gate while a reader is scrolled up; no-op if
+ * higher — page unloaded history back in via "Show earlier"). Stores created
+ * afterwards pick the new limit up at creation. Always succeeds (FE-local).
+ */
+ setChatLimit(limit: number): Promise<ChatLimitResult>;
/**
* Wire the chat-limit unload gate (composition-root injection, called once by
* the shell after it owns the scroll region): unloading old chunks is allowed
@@ -189,15 +212,17 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
const tabsStore: TabsStore = createTabsStore(storageAdapter);
// The chat limit (max loaded chunks per conversation) — a persisted local
- // setting with no UI yet: edit `localStorage["dispatch.chatLimit"]`. The
- // default is written back on first run so the knob is discoverable.
+ // setting surfaced in the sidebar's Settings view. Reactive so the field +
+ // any live-apply re-trim update together. The default is written back on
+ // first run so the knob is discoverable in localStorage too.
const chatLimitStore = createLocalStore<number>("dispatch.chatLimit", {
storage: localStorageOpt,
});
const storedChatLimit = chatLimitStore.load();
- const chatLimit = normalizeChatLimit(storedChatLimit);
+ const normalizedChatLimit = normalizeChatLimit(storedChatLimit);
+ let chatLimit = $state(normalizedChatLimit);
if (storedChatLimit === null) {
- chatLimitStore.save(chatLimit);
+ chatLimitStore.save(normalizedChatLimit);
}
// Unload gate — attached by the shell once it owns the scroll region (see
@@ -225,7 +250,11 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
historySync,
metricsSync,
cache,
- chatLimit,
+ // Read from the persisted store (kept in sync with the reactive `chatLimit`
+ // by `setChatLimit` + boot) so this snapshot doesn't reference the `$state`
+ // — each store captures its limit at creation; live updates go through
+ // `setChatLimit`.
+ chatLimit: normalizeChatLimit(chatLimitStore.load()),
canUnload: () => (unloadGate === null ? true : unloadGate()),
});
}
@@ -516,6 +545,9 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
get reasoningEffort(): ReasoningEffort | null {
return reasoningEffort;
},
+ get chatLimit(): number {
+ return chatLimit;
+ },
get currentConversationId(): string {
return workspaceConversationId();
},
@@ -555,6 +587,15 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
}
},
+ queueMessage(text: string): void {
+ // Only offered while generating (Composer switches to `chat.queue`
+ // when `status === "running"`), so a draft (never generating) never
+ // reaches here. `chat.queue` auto-starts a turn if idle, so even a race
+ // (turn sealed between the status read and the send) is safe — the
+ // server starts a fresh turn with the message as its opening prompt.
+ activeChat.queueMessage(text);
+ },
+
selectModel(model: string): void {
activeModel = model;
const activeId = tabsStore.activeConversationId;
@@ -695,6 +736,24 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
}
},
+ async setChatLimit(limit: number): Promise<ChatLimitResult> {
+ const next = normalizeChatLimit(limit);
+ chatLimitStore.save(next);
+ chatLimit = next;
+ // Propagate to every live chat store. The ACTIVE one is awaited so its
+ // refill (on a raise) lands before the caller returns — letting the
+ // shell preserve scroll over the prepended older chunks. Background
+ // stores refill fire-and-forget. Future stores pick the new limit up at
+ // creation (via the persisted store).
+ const active = getActiveChat();
+ await active.setChatLimit(next);
+ for (const s of chatStores.values()) {
+ if (s !== active) void s.setChatLimit(next);
+ }
+ if (draftStore !== active) void draftStore.setChatLimit(next);
+ return { ok: true, chatLimit: next };
+ },
+
async lspStatus(): Promise<LspResult | null> {
const id = workspaceConversationId();
try {