summaryrefslogtreecommitdiffhomepage
path: root/packages/frontend/src/lib
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-03 01:26:16 +0900
committerAdam Malczewski <[email protected]>2026-06-03 01:26:16 +0900
commitb26821ead97b986f886065b20d3dbde8283daa64 (patch)
tree3c41419afb805d88080392e1c97d233af910b79d /packages/frontend/src/lib
parent4b45d33c256cf580a53054078be6fd7148fa6302 (diff)
downloaddispatch-b26821ead97b986f886065b20d3dbde8283daa64.tar.gz
dispatch-b26821ead97b986f886065b20d3dbde8283daa64.zip
feat(compaction): add UI-driven conversation compaction
Summarize a conversation's older "head" into a structured anchored Markdown summary while preserving the most recent turns verbatim, shrinking context size while keeping the information needed to continue coherently. Triggered by a "Compact conversation" button in Chat Settings (not an agent tool). Approach informed by OpenCode's session/compaction.ts: - Ported SUMMARY_TEMPLATE (Goal / Constraints / Progress / Key Decisions / Next Steps / Critical Context / Relevant Files) and the anchored-summary buildPrompt (re-summarizes a prior summary when present). - Ported the TOOL_OUTPUT_MAX_CHARS (2000) cap on tool results in the summary request. - Simplified tail selection to a fixed recent-turn count (DEFAULT_TAIL_TURNS=2) instead of OpenCode's token-budget splitTurn. core: - New src/compaction/ module (pure, DB-free): template, prompt builder, head/tail selection, transcript renderer with tool-output capping, prior summary extraction. Generic over ChatMessage so callers keep turnId/seq. - db/chunks.ts: rekeyChunks(from,to) relocates a tab's full history to a backup tab (reversible — nothing is deleted). - AgentEvent: compaction-started / -complete / -error variants. api: - AgentManager.compactTab(tempTabId, sourceTabId): side-effect-free resolveConnection() for the compactor model (configured compaction_model_*, else the source tab's own key+model), one-shot tool-less summary generation via a transient Agent, then relocate full history to a fresh backup tab and re-seed the canonical source id with [summary turn + preserved tail]. Source tab is locked (messages queue) during the run; queue drains afterward. - Routes: POST /tabs/:id/compact, GET/PUT /tabs/settings/compaction-model. frontend: - "Compact conversation" button in ModelSelector (Chat Settings), between Working Directory and the agent toggle; idle-gated. - Compaction-model key+model selector in Settings, beside the title model. - Transient placeholder tab shows a large, non-faded "Please wait, compacting conversation…" screen; closing it cancels. Source input locked while running. - Handle compaction-* events: reload compacted source, insert backup tab, refocus source, discard placeholder. tests: core compaction unit tests, rekeyChunks DB test, AgentManager.compactTab orchestration tests, and compaction route tests. All green (713 tests), biome clean, all typechecks pass, frontend builds.
Diffstat (limited to 'packages/frontend/src/lib')
-rw-r--r--packages/frontend/src/lib/components/ChatInput.svelte13
-rw-r--r--packages/frontend/src/lib/components/ChatPanel.svelte27
-rw-r--r--packages/frontend/src/lib/components/ModelSelector.svelte24
-rw-r--r--packages/frontend/src/lib/components/SettingsPanel.svelte89
-rw-r--r--packages/frontend/src/lib/components/SidebarPanel.svelte9
-rw-r--r--packages/frontend/src/lib/tabs.svelte.ts195
-rw-r--r--packages/frontend/src/lib/types.ts11
7 files changed, 361 insertions, 7 deletions
diff --git a/packages/frontend/src/lib/components/ChatInput.svelte b/packages/frontend/src/lib/components/ChatInput.svelte
index 079ef4a..f954be8 100644
--- a/packages/frontend/src/lib/components/ChatInput.svelte
+++ b/packages/frontend/src/lib/components/ChatInput.svelte
@@ -17,6 +17,13 @@ const inputValue = $derived(tabStore.activeTab?.draft ?? "");
const cacheStats = $derived(tabStore.activeTab?.cacheStats ?? null);
const isRunning = $derived(agentStatus === "running");
+// Lock input while this tab is mid-compaction: either it's the source
+// conversation being summarized, or it's the transient placeholder tab.
+const compactLocked = $derived(
+ (tabStore.activeTab?.isCompacting ?? false) ||
+ (tabStore.activeTab?.compactingSource ?? null) !== null ||
+ (tabStore.activeTab?.compactionError ?? null) !== null,
+);
const hasText = $derived(inputValue.trim().length > 0);
// While generating with an empty box, the primary action is "stop". With text
// in the box, it stays "send" (the message is queued behind the live turn).
@@ -88,6 +95,7 @@ function handleKeydown(e: KeyboardEvent) {
}
function submit() {
+ if (compactLocked) return;
const text = inputValue.trim();
if (!text) return;
if (tabId) tabStore.setDraft(tabId, "");
@@ -110,7 +118,8 @@ function primaryAction() {
bind:this={inputEl}
value={inputValue}
rows="1"
- placeholder="Type a message..."
+ placeholder={compactLocked ? "Compaction in progress…" : "Type a message..."}
+ disabled={compactLocked}
class="textarea textarea-ghost flex-1 resize-none leading-normal !min-h-0 h-auto"
onkeydown={handleKeydown}
oninput={handleInput}
@@ -120,7 +129,7 @@ function primaryAction() {
<button
type="button"
class="btn w-20 shrink-0 {showStop ? 'btn-error btn-outline' : 'btn-primary'}"
- disabled={!showStop && !hasText}
+ disabled={compactLocked || (!showStop && !hasText)}
onclick={primaryAction}
title={showStop ? "Stop generation" : "Send message"}
>
diff --git a/packages/frontend/src/lib/components/ChatPanel.svelte b/packages/frontend/src/lib/components/ChatPanel.svelte
index abdec17..ee1b8ef 100644
--- a/packages/frontend/src/lib/components/ChatPanel.svelte
+++ b/packages/frontend/src/lib/components/ChatPanel.svelte
@@ -10,6 +10,11 @@ let isLoadingMore = $state(false);
const renderGroups = $derived(tabStore.activeTab?.renderGroups ?? []);
const activeTabId = $derived(tabStore.activeTab?.id);
+// Compaction placeholder state for the active tab. `compactingSource` is set on
+// a transient placeholder tab while a conversation is being compacted;
+// `compactionError` is set if it failed.
+const compactingSource = $derived(tabStore.activeTab?.compactingSource ?? null);
+const compactionError = $derived(tabStore.activeTab?.compactionError ?? null);
// Stable, turn-scoped render keys. A bubble's identity is `${turnId}:${role}:${n}`
// (n = its index among same-(turn,role) messages) rather than the underlying
@@ -138,14 +143,28 @@ $effect(() => {
{#if isLoadingMore}
<div class="text-center text-xs text-base-content/40 py-2">Loading earlier messages...</div>
{/if}
- {#if renderGroups.length === 0}
+ {#if compactingSource || compactionError}
+ <div class="flex flex-col items-center justify-center h-full gap-4 px-6 text-center">
+ {#if compactionError}
+ <div class="text-2xl font-semibold text-error">Compaction failed</div>
+ <div class="text-base text-base-content/70 max-w-md">{compactionError}</div>
+ <div class="text-sm text-base-content/50">Close this tab to dismiss — your conversation was not changed.</div>
+ {:else}
+ <span class="loading loading-spinner loading-lg text-primary"></span>
+ <div class="text-2xl font-semibold text-base-content">Please wait, compacting conversation…</div>
+ <div class="text-sm text-base-content/50">You can cancel by closing this tab.</div>
+ {/if}
+ </div>
+ {:else if renderGroups.length === 0}
<div class="flex items-center justify-center h-full text-base-content/40 text-sm">
Send a message to start a conversation
</div>
{/if}
- {#each keyedMessages as { m, key } (key)}
- <ChatMessageComponent message={m} tabId={activeTabId} />
- {/each}
+ {#if !compactingSource && !compactionError}
+ {#each keyedMessages as { m, key } (key)}
+ <ChatMessageComponent message={m} tabId={activeTabId} />
+ {/each}
+ {/if}
</div>
<!-- Scroll-to-bottom button -->
diff --git a/packages/frontend/src/lib/components/ModelSelector.svelte b/packages/frontend/src/lib/components/ModelSelector.svelte
index 8601795..3b43ff8 100644
--- a/packages/frontend/src/lib/components/ModelSelector.svelte
+++ b/packages/frontend/src/lib/components/ModelSelector.svelte
@@ -63,6 +63,9 @@ const modelCache = new Map<string, string[]>();
onReasoningChange,
onAgentChange = (_agent: AgentInfo | null) => {},
onWorkingDirectoryChange = (_dir: string | null) => {},
+ onCompact = () => {},
+ canCompact = false,
+ compacting = false,
}: {
keys?: KeyInfo[];
activeKeyId?: string | null;
@@ -77,6 +80,9 @@ const modelCache = new Map<string, string[]>();
onReasoningChange: (effort: string) => void;
onAgentChange?: (agent: AgentInfo | null) => void;
onWorkingDirectoryChange?: (dir: string | null) => void;
+ onCompact?: () => void;
+ canCompact?: boolean;
+ compacting?: boolean;
} = $props();
let showKeyModal = $state(false);
@@ -224,6 +230,24 @@ const modelCache = new Map<string, string[]>();
</div>
</div>
+ <!-- Compact conversation -->
+ <div class="mb-3">
+ <button
+ type="button"
+ class="btn btn-sm btn-outline w-full"
+ disabled={!canCompact || compacting}
+ onclick={onCompact}
+ title="Summarize older turns into a compact anchor, preserving the most recent turns. Opens a new tab while it works; the conversation continues here once done."
+ >
+ {#if compacting}
+ <span class="loading loading-spinner loading-xs"></span>
+ Compacting…
+ {:else}
+ Compact conversation
+ {/if}
+ </button>
+ </div>
+
<!-- Toggle -->
<div class="flex items-center gap-2 mb-3">
<button
diff --git a/packages/frontend/src/lib/components/SettingsPanel.svelte b/packages/frontend/src/lib/components/SettingsPanel.svelte
index 7a810d5..8b28957 100644
--- a/packages/frontend/src/lib/components/SettingsPanel.svelte
+++ b/packages/frontend/src/lib/components/SettingsPanel.svelte
@@ -26,6 +26,10 @@ function selectTheme(theme: Theme): void {
let titleKeyId = $state<string | null>(null);
let titleModelId = $state<string | null>(null);
let availableModels = $state<string[]>([]);
+let compactionKeyId = $state<string | null>(null);
+let compactionModelId = $state<string | null>(null);
+let compactionModels = $state<string[]>([]);
+let loadingCompactionModels = $state(false);
let loadingModels = $state(false);
let autoExpandThinking = $state(appSettings.autoExpandThinking);
let localChunkLimit = $state(appSettings.chunkLimit);
@@ -132,6 +136,19 @@ async function loadSettings(): Promise<void> {
// ignore
}
try {
+ const res = await fetch(`${apiBase}/tabs/settings/compaction-model`);
+ if (res.ok) {
+ const data = (await res.json()) as { keyId: string | null; modelId: string | null };
+ compactionKeyId = data.keyId;
+ if (compactionKeyId) {
+ await loadCompactionModelsForKey(compactionKeyId);
+ }
+ compactionModelId = data.modelId;
+ }
+ } catch {
+ // ignore
+ }
+ try {
const res = await fetch(`${apiBase}/tabs/settings/auto-expand-thinking`);
if (res.ok) {
const data = (await res.json()) as { value: string | null };
@@ -319,6 +336,48 @@ async function onModelChange(e: Event): Promise<void> {
saveTitleModel();
}
+async function loadCompactionModelsForKey(keyId: string): Promise<void> {
+ loadingCompactionModels = true;
+ try {
+ const res = await fetch(`${apiBase}/models/available?keyId=${encodeURIComponent(keyId)}`);
+ if (!res.ok) {
+ compactionModels = [];
+ return;
+ }
+ const data = (await res.json()) as { models: string[] };
+ compactionModels = data.models ?? [];
+ } catch {
+ compactionModels = [];
+ } finally {
+ loadingCompactionModels = false;
+ }
+}
+
+function saveCompactionModel(): void {
+ fetch(`${apiBase}/tabs/settings/compaction-model`, {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ keyId: compactionKeyId, modelId: compactionModelId }),
+ }).catch(() => {});
+}
+
+async function onCompactionKeyChange(e: Event): Promise<void> {
+ const select = e.target as HTMLSelectElement;
+ compactionKeyId = select.value || null;
+ compactionModelId = null;
+ compactionModels = [];
+ if (compactionKeyId) {
+ await loadCompactionModelsForKey(compactionKeyId);
+ }
+ saveCompactionModel();
+}
+
+function onCompactionModelChange(e: Event): void {
+ const select = e.target as HTMLSelectElement;
+ compactionModelId = select.value || null;
+ saveCompactionModel();
+}
+
$effect(() => {
void loadSettings();
});
@@ -374,6 +433,36 @@ $effect(() => {
<div class="divider my-0"></div>
+ <p class="text-xs text-base-content/70">Conversation Compaction Model</p>
+ <p class="text-xs text-base-content/40">Used to summarize a conversation when you compact it. If unset, the tab's own key/model is used.</p>
+
+ <label class="text-xs text-base-content/60">
+ Key
+ <select class="select select-bordered select-sm w-full" onchange={onCompactionKeyChange} value={compactionKeyId ?? ""}>
+ <option value="">Select a key...</option>
+ {#each keys as key (key.id)}
+ <option value={key.id}>{key.id} ({key.provider})</option>
+ {/each}
+ </select>
+ </label>
+
+ <label class="text-xs text-base-content/60">
+ Model
+ <select
+ class="select select-bordered select-sm w-full"
+ onchange={onCompactionModelChange}
+ value={compactionModelId ?? ""}
+ disabled={!compactionKeyId || loadingCompactionModels}
+ >
+ <option value="">{loadingCompactionModels ? "Loading models..." : "Select a model..."}</option>
+ {#each compactionModels as model (model)}
+ <option value={model}>{model}</option>
+ {/each}
+ </select>
+ </label>
+
+ <div class="divider my-0"></div>
+
<p class="text-xs text-base-content/70">Chat</p>
<label class="flex items-center gap-2 cursor-pointer">
<input
diff --git a/packages/frontend/src/lib/components/SidebarPanel.svelte b/packages/frontend/src/lib/components/SidebarPanel.svelte
index 519f411..d20ac79 100644
--- a/packages/frontend/src/lib/components/SidebarPanel.svelte
+++ b/packages/frontend/src/lib/components/SidebarPanel.svelte
@@ -43,6 +43,9 @@ const {
onReasoningChange,
onAgentChange = (_agent: AgentInfo | null) => {},
onWorkingDirectoryChange = (_dir: string | null) => {},
+ onCompact = () => {},
+ canCompact = false,
+ compacting = false,
onAddKey = () => {},
}: {
keys?: KeyInfo[];
@@ -64,6 +67,9 @@ const {
onReasoningChange: (effort: string) => void;
onAgentChange?: (agent: AgentInfo | null) => void;
onWorkingDirectoryChange?: (dir: string | null) => void;
+ onCompact?: () => void;
+ canCompact?: boolean;
+ compacting?: boolean;
onAddKey?: () => void;
} = $props();
@@ -169,6 +175,9 @@ function contentClass(_selected: string): string {
{onAgentChange}
{workingDirectory}
{onWorkingDirectoryChange}
+ {onCompact}
+ {canCompact}
+ {compacting}
/>
{:else if panel.selected === "Key Usage"}
<KeyUsage {keys} {apiBase} />
diff --git a/packages/frontend/src/lib/tabs.svelte.ts b/packages/frontend/src/lib/tabs.svelte.ts
index 9975d7b..3edd1e3 100644
--- a/packages/frontend/src/lib/tabs.svelte.ts
+++ b/packages/frontend/src/lib/tabs.svelte.ts
@@ -195,6 +195,16 @@ export interface Tab {
* `usage` event arrives. Drives the "Cache Rate" sidebar view.
*/
cacheStats?: CacheStats;
+ /**
+ * Compaction UI state. `compactingSource` is set on a TRANSIENT placeholder
+ * tab while it hosts the "compacting…" screen, naming the conversation being
+ * compacted. `isCompacting` is set on the SOURCE tab while its compaction is
+ * in flight (input locked). Both clear when compaction settles.
+ */
+ compactingSource?: string | null;
+ isCompacting?: boolean;
+ /** Error message shown on a placeholder tab when compaction fails. */
+ compactionError?: string | null;
}
/**
@@ -315,6 +325,9 @@ export function createTabStore() {
manualTitle: false,
oldestLoadedSeq: null,
totalChunks: 0,
+ compactingSource: null,
+ isCompacting: false,
+ compactionError: null,
};
tabs = [...tabs, tab];
activeTabId = id;
@@ -947,6 +960,145 @@ export function createTabStore() {
return restored.length;
}
+ /**
+ * Start a conversation compaction (UI-driven). Creates a TRANSIENT
+ * placeholder tab that shows the "compacting…" screen, switches to it, and
+ * kicks off the backend compaction of `sourceTabId`. Outcome arrives via the
+ * `compaction-*` WS events (see handleEvent). Closing the placeholder tab
+ * before completion cancels it (DELETE aborts the in-flight summary).
+ */
+ async function startCompaction(sourceTabId: string): Promise<void> {
+ const source = getTabById(sourceTabId);
+ if (!source) return;
+ if (source.isCompacting) return;
+
+ const tempId = generateId();
+ // Create the placeholder tab on the backend (so DELETE-on-close can
+ // abort the run) and locally (so we can switch to it and show the UI).
+ try {
+ await fetch(`${config.apiBase}/tabs`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ id: tempId, title: "Compacting…" }),
+ });
+ } catch {
+ // Continue — the run is driven server-side via the compact endpoint.
+ }
+
+ const placeholder: Tab = {
+ id: tempId,
+ title: "Compacting…",
+ chunks: [],
+ live: [],
+ renderGroups: [],
+ liveTurnId: null,
+ agentStatus: "idle",
+ keyId: null,
+ modelId: null,
+ reasoningEffort: DEFAULT_REASONING_EFFORT,
+ currentAssistantId: null,
+ tasks: [],
+ injectedSkills: [],
+ parentTabId: null,
+ persistent: true,
+ agentSlug: null,
+ agentScope: null,
+ agentModels: null,
+ workingDirectory: null,
+ queuedMessages: [],
+ chunkLimit: appSettings.chunkLimit,
+ draft: "",
+ manualTitle: true,
+ oldestLoadedSeq: null,
+ totalChunks: 0,
+ compactingSource: sourceTabId,
+ isCompacting: false,
+ compactionError: null,
+ };
+ tabs = [...tabs, placeholder];
+ activeTabId = tempId;
+ updateTab(sourceTabId, { isCompacting: true });
+
+ try {
+ const res = await fetch(`${config.apiBase}/tabs/${tempId}/compact`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ sourceTabId }),
+ });
+ if (!res.ok) {
+ const msg = `Compaction request failed (HTTP ${res.status}).`;
+ updateTab(sourceTabId, { isCompacting: false });
+ if (getTabById(tempId)) updateTab(tempId, { compactionError: msg, compactingSource: null });
+ }
+ } catch {
+ const msg = "Could not reach the server to start compaction.";
+ updateTab(sourceTabId, { isCompacting: false });
+ if (getTabById(tempId)) updateTab(tempId, { compactionError: msg, compactingSource: null });
+ }
+ }
+
+ /**
+ * Finish a completed compaction: the canonical conversation now lives on
+ * `sourceTabId` (re-seeded with summary + preserved tail), the full prior
+ * history was relocated to `backupTabId`. Reload the source tab's chunks,
+ * insert the backup tab into the sidebar, switch focus back to the source,
+ * and discard the transient placeholder.
+ */
+ async function finishCompaction(ev: {
+ tempTabId: string;
+ sourceTabId: string;
+ backupTabId: string;
+ backupTitle: string;
+ }): Promise<void> {
+ // Reload the re-seeded source conversation from the backend.
+ updateTab(ev.sourceTabId, { isCompacting: false });
+ await reloadChunksFromApi(ev.sourceTabId);
+
+ // Insert the backup tab (full pre-compaction history) if not present.
+ if (!getTabById(ev.backupTabId)) {
+ const win = await fetchChunkWindow(ev.backupTabId, { limit: 100 });
+ const src = getTabById(ev.sourceTabId);
+ const backup: Tab = {
+ id: ev.backupTabId,
+ title: ev.backupTitle,
+ chunks: win.chunks,
+ live: [],
+ renderGroups: deriveRenderGroups(win.chunks, []),
+ liveTurnId: null,
+ agentStatus: "idle",
+ keyId: src?.keyId ?? null,
+ modelId: src?.modelId ?? null,
+ reasoningEffort: src?.reasoningEffort ?? DEFAULT_REASONING_EFFORT,
+ currentAssistantId: null,
+ tasks: [],
+ injectedSkills: [],
+ parentTabId: null,
+ persistent: true,
+ agentSlug: src?.agentSlug ?? null,
+ agentScope: src?.agentScope ?? null,
+ agentModels: src?.agentModels ?? null,
+ workingDirectory: src?.workingDirectory ?? null,
+ queuedMessages: [],
+ chunkLimit: appSettings.chunkLimit,
+ draft: "",
+ manualTitle: true,
+ oldestLoadedSeq: win.oldestSeq,
+ totalChunks: win.total,
+ compactingSource: null,
+ isCompacting: false,
+ compactionError: null,
+ };
+ tabs = [...tabs, backup];
+ evictChunks(ev.backupTabId);
+ }
+
+ // Switch focus back to the (compacted) source tab and drop the
+ // placeholder. If the placeholder was the active tab, focus moves to
+ // the source conversation.
+ if (activeTabId === ev.tempTabId) activeTabId = ev.sourceTabId;
+ tabs = tabs.filter((t) => t.id !== ev.tempTabId);
+ }
+
function handleEvent(event: AgentEvent & { tabId?: string }): void {
const tabId = event.tabId;
@@ -1450,6 +1602,48 @@ export function createTabStore() {
});
break;
}
+ case "compaction-started": {
+ const ev = event as AgentEvent & { tempTabId: string; sourceTabId: string };
+ // Lock the source tab's input while compaction runs.
+ if (getTabById(ev.sourceTabId)) {
+ updateTab(ev.sourceTabId, { isCompacting: true });
+ }
+ // Mark the placeholder tab so it shows the "compacting…" screen.
+ if (getTabById(ev.tempTabId)) {
+ updateTab(ev.tempTabId, { compactingSource: ev.sourceTabId });
+ }
+ break;
+ }
+ case "compaction-complete": {
+ const ev = event as AgentEvent & {
+ tempTabId: string;
+ sourceTabId: string;
+ backupTabId: string;
+ backupTitle: string;
+ };
+ void finishCompaction(ev);
+ break;
+ }
+ case "compaction-error": {
+ const ev = event as AgentEvent & {
+ tempTabId: string;
+ sourceTabId: string;
+ error: string;
+ };
+ if (getTabById(ev.sourceTabId)) {
+ updateTab(ev.sourceTabId, { isCompacting: false });
+ }
+ // Surface the error on the placeholder tab (if still open) so the
+ // user sees why it failed; the source conversation is untouched.
+ const tmp = getTabById(ev.tempTabId);
+ if (tmp) {
+ updateTab(ev.tempTabId, {
+ compactingSource: null,
+ compactionError: ev.error,
+ });
+ }
+ break;
+ }
}
}
@@ -2130,6 +2324,7 @@ export function createTabStore() {
promoteTab,
openAgentTab,
setWorkingDirectory,
+ startCompaction,
// Exposed so tests can drive the real reactive code path that the
// WS callback uses in production. Not intended for use in
// components — they should rely on the WS subscription instead.
diff --git a/packages/frontend/src/lib/types.ts b/packages/frontend/src/lib/types.ts
index 384028c..bb7c169 100644
--- a/packages/frontend/src/lib/types.ts
+++ b/packages/frontend/src/lib/types.ts
@@ -220,7 +220,16 @@ export type AgentEvent =
*/
reason?: "interrupt" | "continuation";
}
- | { type: "message-cancelled"; tabId: string; messageId: string };
+ | { type: "message-cancelled"; tabId: string; messageId: string }
+ | { type: "compaction-started"; tempTabId: string; sourceTabId: string }
+ | {
+ type: "compaction-complete";
+ tempTabId: string;
+ sourceTabId: string;
+ backupTabId: string;
+ backupTitle: string;
+ }
+ | { type: "compaction-error"; tempTabId: string; sourceTabId: string; error: string };
export interface TaskItem {
/** Stable positional id for Svelte keying only; never shown to the model. */