Commit 1a97e387d for llama.cpp
commit 1a97e387d37cbb6d0d340278ef127b0a9d2f968f
Author: Pascal <admin@serveurperso.com>
Date: Tue Oct 27 06:49:27 2026 +0100
ui: add conversation compaction (#27401)
* ui: add conversation compaction
Add a compaction action that asks the model to summarize the
conversation so far and stores the summary in a new compaction node
appended to the branch. Later requests slice the branch at the last
compaction node, keeping the system prefix and continuing from the
summary instead of the full history, which frees up context while the
complete history stays visible and navigable in the UI.
The slice is applied once at the request funnel, covering the plain
send, the agentic flow and the KV cache pre-encode, plus the continue
path that bypasses it. The summary request is a one-shot outside the
SSE replay machinery, streamed live into the node with a full rollback
on abort, error or empty result.
The action is exposed as a /compact slash command and a Compact button
in the context gauge popup, with an overridable summarization prompt
in the settings.
* ui: pre-fill KV cache and refresh context gauge after compaction
Once the summary is stored, pre-encode the compacted branch and stamp
the returned prompt timings on the compaction node: re-encoding the
reduced prefix is part of the compaction itself, so the server KV
cache and the context gauge reflect the summary right away.
The summary stream is registered under a prefixed identity
(compact::convId), so the visibility kick and byte-offset resume
protect it through tab switches while the reload probe never
reattaches it as a chat turn. The stamped node becomes the shared
context truth for the gauge and the processing state restore, and the
stale live state that kept the gauge full is cleared.
* chore: format
* ui: allow a dedicated model for conversation compaction
An opt-in setting reveals a model selector whose options come from the
live router model list. The chosen model writes the summary and is
loaded on demand when it is not resident, while the default path keeps
resolving to the conversation's own model.
* ui: align the compaction model field with project conventions
* ui: fix the model option helpers import path
The compaction model selector pulls filterModelOptions and
groupModelOptions from the models component folder, where they no
longer live, so the build fails to resolve the module. Point the
import at the navigation folder, which now owns them.
Missed during the rebase: the helpers moved in a commit that does
not touch any compaction file, so nothing conflicted.
* ui: import the model option helpers from models/utils
* ui: follow the moved models selector in the compaction model field
The models manager moved ModelsSelectorList under ModelsSelector and
the model option helpers to /utils, where groupModelOptions takes
the options alone. Drop the stale copy the rebase kept and read the
helpers from their new home.
* ui: drop the dedicated compaction model
A summary written by another model loses the prefix the conversation
model cached, so the whole history is prefilled again, and the model
then resumes from text it did not write. The conversation model
always writes its own summary.
* ui: keep the last steps of a compacted conversation
A compaction now folds the steps the model reads but the last ones,
a step being a user message or an assistant message with its tool
results, and stores how many it kept on the compaction node. The
model then reads the system prefix, the summary, the kept steps and
what follows.
The summary request is the request the chat sends, with its options,
tools and message shaping, cut before the preserved steps: the server
reuses the prefix it cached and only the instruction is new. The
number of preserved steps is a setting, 10 by default.
* ui: run every compaction summary through one path
Three callers needed the same thing - the automatic pre-encode pass, the
action in the context gauge, and re-running a summary from the block - and
each of them had grown its own copy of the request, the streaming and the
storing.
There is one path now: a summarization owns a node, streams into it, and
reports itself through a ledger the rest of the app reads (`isCompacting`,
which node belongs to which conversation). Re-running a summary is the same
call on an existing node, so the two entry points cannot drift apart again.
* ui: give the compaction row its own summary UI
The summary was a line of text in a tool-call-shaped collapsible, which is
how a compaction reads as a side note rather than as the thing the rest of
the conversation now stands on.
The row is its own block: a divider that names the boundary, and the summary
inside it with the model that wrote it, its statistics, and the same actions
an assistant turn has - copy, edit, fork, regenerate and delete all apply to
the summary, and it collapses to the divider once a summary is in, because
the shrunk context is the headline and the prose is the detail.
The divider is a variant of the shared collapsible block, which also needed
the toggle and the title split into snippets so a caller can lay them out
without copying the markup. The action icons no longer take a role: they
render what a message offers, and a compaction offers the same as any other
stored message.
* ui: report compaction progress on the row
The row owns the run, so the row says how it is going: the prompt progress
in the header while the context is being read, the phase once the summary
starts streaming, and the token count once it is in. Before the first
summary token there is nothing to collapse, so the header stands on its own
instead of offering an empty block.
* ui: keep the summarized turns quiet while a compaction runs
The turns being summarized used to keep their own "Processing..." indicator
and live reading stats while the summary streamed, so the conversation said
the same thing twice in two places. The row reports the run, so the turns it
replaces stay silent until it is done.
* ui: hand the compact action to the context gauge
Compacting is a decision about the context, so the action belongs with the
numbers that show the context filling up rather than inside the form: it sits
at the bottom of both the hover card and the drawer, under the breakdown it
acts on, quiet enough to stay a footnote and disabled - with the row's own
spinner - while a summary is already running.
* ui: follow the bottom, otherwise leave the view alone
The screen scrolled itself with a controller of observers, intervals,
direction heuristics and landing timers, and still managed to move the view
while the reader was reading: any mutation that grew the branch was treated
as a reason to keep following.
The page is the scroller, so it follows when the reader sits at the bottom,
and stays where it is otherwise. A resize observer plus native scroll
anchoring does the rest, and `pointerdown` buys the reader a moment of grace
to be caught mid-drag.
* ui: expand collapsible bodies with a height transition
Expanding a block snapped it open: the body was mounted the moment `open`
flipped, so a tall block moved the conversation around it in one frame.
The body now goes through the same region the models table uses. The height
interpolates and the visibility follows it, so a body is unmounted once the
collapse has finished rather than the moment it starts - a closed block
still costs nothing while tokens stream past it.
* ui: open a compaction summary at the top of the screen
Toggling a summary left the reader wherever they were, so a conversation that
had just compacted showed the divider at the bottom of the screen and read
down from there.
A toggle now scrolls the page once and lands the divider a breath under the
tabs, both ways: the summary is read from its beginning, and closing it returns
to the same boundary instead of wherever the page happens to come to rest. The
scroll waits for the body to reach its height, because before that the page is
too short to scroll the divider up there and the call would clamp.
That wait is the shared collapse duration rather than a transition event: an
engine without `interpolate-size` animates the region's grid row instead of its
height, so a listener on `height` would never fire there and the page would not
move at all.
* ui: spin the compaction icon while the summary runs
The divider reported the phase in its title while the icon still showed a
finished block, so a run in progress and a summary that had landed looked the
same in the one place the eye reads first. The icon spins while the summary is
running and folds back once it is in.
* ui: let the compaction summary speak without the reasoning trimmings
The summary is the thing the conversation now stands on, not a note beside
it, and the conversation model always writes it, so the body drops the
reasoning block's rule and indent (`flushBody` keeps them for the blocks
that borrow them) and the model selector under the summary is gone - there
is nothing left to swap.
* ui: drop the model override from the compaction path
The summary row no longer offers a model selector, so nothing hands
regenerateCompaction a model anymore. Remove the parameter from the
whole path, with the on-demand load of another model it carried: the
summary always comes from the model the request funnel resolves.
* ui: let the compaction summary think as the chat does
The summary request forced thinking off, overriding the reasoning
effort the user chose. The summary is the text the conversation
resumes from, the task where reasoning helps the most, so the request
now carries the very options of the chat.
* ui: pre-encode the compacted branch as the next turn reads it
The post-compaction pre-encode sent the branch without tools, while
the tools render ahead of the messages, so it warmed a prefix the next
turn never reads and that turn prefilled the whole compacted context
again. The pre-encode now sends the tools, message shaping and
reasoning handling of the chat, so the next turn only prefills its new
message.
* ui: pre-encode the branch the next turn actually reads
The per-turn pre-encode sent the messages of the request plus one
assistant message holding the streamed text, without tools. With
tools on, it replaced the slot the next turn extends with a prefix
nobody reads again, and after an agentic turn its history did not
match the stored calls and results. Without agentic, it never ran.
Both pre-encodes now read the stored branch up to its leaf, cut at the
last compaction, and send it with the tools, message shaping and
reasoning handling of the chat. The plain completion path triggers it
too.
* ui: refuse a compaction summary the output limit cut
The stream handler read every chunk but dropped the finish reason, so
a summary cut by max_tokens folded the conversation into half a text.
The handlers now hand the finish reason to onComplete, and a summary
that ends on length rolls the compaction back with an error.
The summary was framed through a replacement string, so $& or 32560 in
it changed the text the model read; it is framed as written now.
* ui: wait for the collapse transition in the tool call tests
The collapsible body now unmounts once its collapse animation ends rather
than as soon as the block closes, so the "folds back" assertions ran before
the body was gone. Wait out COLLAPSIBLE_TRANSITION_MS first.
Assisted-by: Claude (Opus 5.5 Medium)
---------
Co-authored-by: Aleksander Grygier <aleksander.grygier@gmail.com>
diff --git a/tools/ui/README.md b/tools/ui/README.md
index 2ecbc8f51..c6ec54721 100644
--- a/tools/ui/README.md
+++ b/tools/ui/README.md
@@ -406,8 +406,7 @@ Hooks are the thin view-layer between components and stores: they own UI concern
| `use-chat-message-edit-context` | Message edit context (content + extras) |
| `use-chat-screen-drag-and-drop` | Drag-and-drop state machine |
| `use-chat-screen-file-upload` | File upload queue + capability validation |
-| `use-chat-screen-scroll` | Scroll container binding + navigation guard |
-| `use-auto-scroll` | Auto-scroll controller for streaming |
+| `use-stick-to-bottom` | Follows new content while the reader sits at the bottom of a scroller |
| `use-marquee-selection` | Shift+click / marquee range selection |
| `use-keyboard-shortcuts` | Global keyboard shortcuts |
| `use-settings-navigation` | Settings section navigation |
diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatForm.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatForm.svelte
index 91edac1d8..639577134 100644
--- a/tools/ui/src/lib/components/app/chat/ChatForm/ChatForm.svelte
+++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatForm.svelte
@@ -144,6 +144,7 @@
);
const pickers = useChatFormPickers({
+ canCompact: () => chatStore.canCompact(conversationsStore.activeMessages),
focusInput: refocusInput,
getCaretOffset: () => inputRef?.getCaretOffset(),
getCwd: () => cwd,
@@ -163,6 +164,7 @@
.getServers()
.some((s) => s.enabled && prefs.isServerToolsEnabled(s.id) && s.url.trim());
},
+ onCompact: () => chatStore.compactConversation(),
openModelSelector: () => chatFormActionsRef?.openModelSelector(),
setCaretOffset: (offset) => inputRef?.setCaretOffset(offset),
setValue: (v) => {
diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormContextGauge/ContextGaugePopup.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormContextGauge/ContextGaugePopup.svelte
index 1fdd26d8b..8a2cb3c00 100644
--- a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormContextGauge/ContextGaugePopup.svelte
+++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormContextGauge/ContextGaugePopup.svelte
@@ -8,9 +8,11 @@
gaugePopup,
gaugePopupClose
} from './gauge-popup.svelte';
+ import { FoldVertical, Loader2 } from '@lucide/svelte';
+ import { Button } from '$lib/components/ui/button';
import * as Drawer from '$lib/components/ui/drawer';
import { useContextGauge } from '$lib/hooks/use-context-gauge.svelte';
- import { deviceStore } from '$lib/stores';
+ import { agenticStore, chatStore, conversationsStore, deviceStore } from '$lib/stores';
import { formatParameters } from '$lib/utils/formatters';
const gauge = useContextGauge();
@@ -53,6 +55,25 @@
gauge.contextTotal > 0 &&
(gauge.activeModelId !== null || gauge.isActiveModelLoaded)
);
+
+ const showCompactButton = $derived(chatStore.canCompact(conversationsStore.activeMessages));
+
+ const activeConvId = $derived(conversationsStore.activeConversation?.id ?? null);
+
+ // A summary in flight keeps the card open for the numbers, so the action
+ // reports the run instead of inviting a second one.
+ const isCompacting = $derived(activeConvId !== null && chatStore.isCompacting(activeConvId));
+
+ const isCompactDisabled = $derived.by(() => {
+ if (!activeConvId) return true;
+
+ return chatStore.isChatLoading(activeConvId) || agenticStore.isRunning(activeConvId);
+ });
+
+ function handleCompact() {
+ gaugePopupClose();
+ chatStore.compactConversation();
+ }
</script>
{#snippet usage()}
@@ -62,6 +83,26 @@
</span>
{/snippet}
+{#snippet compactAction()}
+ {#if showCompactButton}
+ <Button
+ class="my-1 mt-3 w-full gap-1.5"
+ disabled={isCompactDisabled}
+ onclick={handleCompact}
+ size="sm"
+ variant="tertiary"
+ >
+ {#if isCompacting}
+ <Loader2 class="h-3.5 w-3.5 animate-spin" />
+ Compacting...
+ {:else}
+ <FoldVertical class="h-3.5 w-3.5" />
+ Compact conversation
+ {/if}
+ </Button>
+ {/if}
+{/snippet}
+
{#snippet body()}
{#if gauge.activeModelId !== null && !gauge.isActiveModelLoaded}
<ContextGaugeLoadModel
@@ -123,6 +164,8 @@
{@render usage()}
{@render body()}
+
+ {@render compactAction()}
</div>
</Drawer.Content>
</Drawer.Root>
@@ -145,6 +188,8 @@
</div>
{@render body()}
+
+ {@render compactAction()}
</div>
</div>
{/if}
diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessage.svelte b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessage.svelte
index 46d05338b..0c32882ef 100644
--- a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessage.svelte
+++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessage.svelte
@@ -2,6 +2,7 @@
import { goto } from '$app/navigation';
import {
ChatMessageAssistant,
+ ChatMessageCompaction,
ChatMessageMcpPrompt,
ChatMessageSynthetic,
ChatMessageSystem,
@@ -14,7 +15,7 @@
SYSTEM_MESSAGE_PLACEHOLDER
} from '$lib/constants';
import { setChatMessageActionsContext, setChatMessageEditContext } from '$lib/contexts';
- import { AgenticSectionType, AttachmentType, MessageRole } from '$lib/enums';
+ import { AgenticSectionType, AttachmentType, MessageRole, MessageType } from '$lib/enums';
import { DatabaseService } from '$lib/services/database.service';
import { chatStore, conversationsStore, deviceStore } from '$lib/stores';
import type {
@@ -60,6 +61,7 @@
// Synthetic cwd-change messages render with the folder-row UI instead
// of a user bubble. The persisted flag is the single source of truth.
let isSynthetic = $derived(Boolean(message.isSynthetic));
+ let isCompaction = $derived(message.type === MessageType.COMPACTION);
let rawEditContent = $derived.by(() => {
if (message.role !== MessageRole.ASSISTANT) return undefined;
@@ -313,6 +315,10 @@
chatActions.regenerateWithBranching(message, modelOverride);
}
+ function handleRegenerateCompaction() {
+ chatActions.regenerateCompaction(message);
+ }
+
function handleContinue() {
chatActions.continueAssistantMessage(message);
}
@@ -409,6 +415,8 @@
<ChatMessageSystem bind:textareaElement class={className} {message} />
{:else if mcpPromptExtra}
<ChatMessageMcpPrompt class={className} mcpPrompt={mcpPromptExtra} {message} />
+ {:else if isCompaction}
+ <ChatMessageCompaction class={className} {message} onRegenerate={handleRegenerateCompaction} />
{:else if isSynthetic}
<ChatMessageSynthetic class={className} {message} />
{:else if message.role === MessageRole.USER}
diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageAssistant/ChatMessageAssistant.svelte b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageAssistant/ChatMessageAssistant.svelte
index 15683c3c3..ff23acf3c 100644
--- a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageAssistant/ChatMessageAssistant.svelte
+++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageAssistant/ChatMessageAssistant.svelte
@@ -52,6 +52,9 @@
let isStreaming = $derived(chatStore.isStreaming());
let hasNoContent = $derived(!message?.content?.trim());
let isActivelyProcessing = $derived(isCurrentlyLoading || isStreaming);
+ // A compaction summary reports its own progress on the compaction row, so
+ // this turn stays quiet while it runs instead of duplicating the indicator.
+ let isCompacting = $derived(chatStore.isCompacting(message.convId));
// during a router auto-load the message has no model yet: target the model frozen in the
// persisted stream state (survives a reload), then fall back to the dropdown selection
@@ -66,6 +69,7 @@
let showProcessingInfoTop = $derived(
message?.role === MessageRole.ASSISTANT &&
isActivelyProcessing &&
+ !isCompacting &&
hasNoContent &&
!isAgentic &&
isLastAssistantMessage
@@ -74,6 +78,7 @@
let showProcessingInfoBottom = $derived(
message?.role === MessageRole.ASSISTANT &&
isActivelyProcessing &&
+ !isCompacting &&
(!hasNoContent || isAgentic) &&
isLastAssistantMessage
);
@@ -212,7 +217,6 @@
onRawOutputToggle={(enabled) => (showRawOutput = enabled)}
{onRegenerate}
rawOutputEnabled={showRawOutput}
- role={MessageRole.ASSISTANT}
showRawOutputSwitch={currentConfig.showRawOutputSwitch}
/>
{/if}
diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageAssistant/ChatMessageAssistantStatistics.svelte b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageAssistant/ChatMessageAssistantStatistics.svelte
index 0026c31c3..bd07fd6dd 100644
--- a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageAssistant/ChatMessageAssistantStatistics.svelte
+++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageAssistant/ChatMessageAssistantStatistics.svelte
@@ -31,6 +31,15 @@
promptMs={liveLlm.prompt_ms}
promptTokens={liveLlm.prompt_n}
/>
+{:else if showMessageStats && message.timings?.compaction}
+ {@const summary = message.timings.compaction}
+ <ChatMessageStatistics
+ mode={ChatMessageStatisticsMode.GENERATION}
+ predictedMs={summary.predicted_ms}
+ predictedTokens={summary.predicted_n}
+ promptMs={summary.prompt_ms}
+ promptTokens={summary.prompt_n}
+ />
{:else if showMessageStats && message.timings && message.timings.predicted_n && message.timings.predicted_ms}
{@const agentic = message.timings.agentic}
<ChatMessageStatistics
diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageCompaction.svelte b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageCompaction.svelte
new file mode 100644
index 000000000..da6abdaba
--- /dev/null
+++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageCompaction.svelte
@@ -0,0 +1,156 @@
+<script lang="ts">
+ import { FoldVertical, Loader2 } from '@lucide/svelte';
+ import {
+ ChatMessageActionIcons,
+ ChatMessageAssistantStatistics,
+ ChatMessageEditForm,
+ CollapsibleContentBlock,
+ MarkdownContent
+ } from '$lib/components/app';
+ import { COLLAPSIBLE_TRANSITION_MS } from '$lib/constants';
+ import { getChatMessageEditContext } from '$lib/contexts';
+ import { useProcessingState } from '$lib/hooks/use-processing-state.svelte';
+ import { chatStore, conversationsStore, settingsStore } from '$lib/stores';
+ import type { DatabaseMessage } from '$lib/types';
+ import { compactionTokensBefore, formatNumber } from '$lib/utils';
+
+ interface Props {
+ class?: string;
+ message: DatabaseMessage;
+ onRegenerate: () => void;
+ }
+
+ let { class: className = '', message, onRegenerate }: Props = $props();
+
+ const COMPACTION_HEADER = 'Conversation compacted';
+ const COMPACTION_RUNNING_LABEL = 'Compacting conversation';
+ const COMPACTION_GENERATING_LABEL = 'Generating summary';
+
+ // The shared row context, so the summary is editable and branchable through
+ // the same actions as a chat turn.
+ const editCtx = getChatMessageEditContext();
+
+ const currentConfig = $derived(settingsStore.config);
+ const hasContent = $derived(message.content.trim().length > 0);
+
+ // Only the node a running summary writes to reports progress: a finished
+ // compaction further up the branch keeps its headline while another one runs.
+ const isSummarizing = $derived(chatStore.getCompactingNodeId(message.convId) === message.id);
+
+ const processingState = useProcessingState();
+
+ // The summary request feeds the shared processing state, so the body shows
+ // the same prompt progress line a chat turn does while it waits.
+ $effect(() => {
+ if (isSummarizing) processingState.startMonitoring();
+ });
+
+ // The header carries the run: the prompt progress until the summary starts
+ // streaming, the phase after that. There is nothing to collapse before the
+ // first summary token arrives.
+ const runningLabel = $derived.by(() => {
+ // The prompt is through: whatever streams in now is the summary itself.
+ if (hasContent) return `${COMPACTION_RUNNING_LABEL} — ${COMPACTION_GENERATING_LABEL}`;
+
+ const progress = processingState.getPromptProgressText();
+
+ return progress
+ ? `${COMPACTION_RUNNING_LABEL} — ${progress}`
+ : `${COMPACTION_RUNNING_LABEL}...`;
+ });
+
+ // Shrinking the branch is the feature's own headline, so the finished
+ // toggle reports the context size it compacted.
+ const tokensBefore = $derived(compactionTokensBefore(conversationsStore.activeMessages, message));
+
+ const completedLabel = $derived(
+ tokensBefore === null
+ ? COMPACTION_HEADER
+ : `${COMPACTION_HEADER} from ${formatNumber(tokensBefore)}`
+ );
+
+ const title = $derived(isSummarizing || !hasContent ? runningLabel : completedLabel);
+
+ let open = $state(false);
+ // A toggle by hand outranks the in-progress expansion, the way the agentic
+ // sections treat their own overrides.
+ let userChoseOpen = $state<boolean | null>(null);
+
+ // The progress lives in the body (as it does for an assistant turn), so the
+ // row opens while the summary runs and collapses once it is done.
+ $effect(() => {
+ if (userChoseOpen !== null) return;
+
+ open = isSummarizing;
+ });
+
+ let rootEl = $state<HTMLDivElement>();
+ // The scroll waiting for the body to reach its height, so closing the block
+ // before that leaves no scroll behind it.
+ let settleTimer: ReturnType<typeof setTimeout> | null = null;
+
+ /**
+ * A toggle hands the reader the block's own boundary, just below the tabs: one
+ * scroll, and only once the body has its height - before that the page is too
+ * short to scroll the divider up there and the call would clamp.
+ */
+ function handleToggle() {
+ userChoseOpen = open;
+ cancelSettledScroll();
+
+ if (!rootEl) return;
+
+ settleTimer = setTimeout(() => {
+ settleTimer = null;
+ rootEl?.scrollIntoView({ behavior: 'smooth', block: 'start' });
+ }, COLLAPSIBLE_TRANSITION_MS);
+ }
+
+ function cancelSettledScroll() {
+ if (settleTimer === null) return;
+
+ clearTimeout(settleTimer);
+ settleTimer = null;
+ }
+
+ $effect(() => cancelSettledScroll);
+</script>
+
+<div bind:this={rootEl} class="scroll-mt-[calc(var(--chat-tabs-height,0px)+1rem)] {className}">
+ {#if editCtx.isEditing}
+ <ChatMessageEditForm />
+ {:else}
+ <CollapsibleContentBlock
+ bind:open
+ class="my-2"
+ collapsible={hasContent}
+ flushBody
+ icon={isSummarizing ? Loader2 : FoldVertical}
+ iconClass={isSummarizing ? 'h-3.5 w-3.5 animate-spin' : 'h-3.5 w-3.5'}
+ onToggle={handleToggle}
+ shimmerTitle={isSummarizing || !hasContent}
+ {title}
+ variant="divider"
+ >
+ <MarkdownContent class="text-muted-foreground" content={message.content} />
+
+ <!-- The summary's own statistics live with the summary, so the divider
+ stays a one-line headline. Only the conversation model writes the
+ summary, so there is no selector to swap it with. -->
+ <div class="info my-6 grid gap-4 tabular-nums">
+ <ChatMessageAssistantStatistics
+ isLoading={chatStore.isLoading}
+ {message}
+ {processingState}
+ showMessageStats={currentConfig.showMessageStats}
+ />
+ </div>
+
+ {#if hasContent && !isSummarizing}
+ <!-- Copy, edit, fork, regenerate and delete all apply to the summary:
+ regenerate re-runs it in place. Continue stays a chat-turn flow. -->
+ <ChatMessageActionIcons actionsPosition="left" justify="start" {onRegenerate} />
+ {/if}
+ </CollapsibleContentBlock>
+ {/if}
+</div>
diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageMcpPrompt/ChatMessageMcpPrompt.svelte b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageMcpPrompt/ChatMessageMcpPrompt.svelte
index 1163c8a94..68ea17a37 100644
--- a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageMcpPrompt/ChatMessageMcpPrompt.svelte
+++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageMcpPrompt/ChatMessageMcpPrompt.svelte
@@ -5,7 +5,7 @@
ChatMessageMcpPromptContent
} from '$lib/components/app';
import { getChatMessageEditContext } from '$lib/contexts';
- import { McpPromptVariant, MessageRole } from '$lib/enums';
+ import { McpPromptVariant } from '$lib/enums';
import type { DatabaseMessageExtraMcpPrompt } from '$lib/types';
interface Props {
@@ -36,7 +36,7 @@
{#if message.timestamp}
<div class="max-w-[80%]">
- <ChatMessageActionIcons actionsPosition="right" justify="end" role={MessageRole.USER} />
+ <ChatMessageActionIcons actionsPosition="right" justify="end" />
</div>
{/if}
{/if}
diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageSystem/ChatMessageSystem.svelte b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageSystem/ChatMessageSystem.svelte
index 7f4db944f..98bfa2e11 100644
--- a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageSystem/ChatMessageSystem.svelte
+++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageSystem/ChatMessageSystem.svelte
@@ -5,7 +5,7 @@
import { Card } from '$lib/components/ui/card';
import { INPUT_CLASSES } from '$lib/constants';
import { getChatMessageEditContext } from '$lib/contexts';
- import { KeyboardKey, MessageRole } from '$lib/enums';
+ import { KeyboardKey } from '$lib/enums';
import { settingsStore } from '$lib/stores';
import { autoResizeTextarea, isIMEComposing } from '$lib/utils';
@@ -191,7 +191,7 @@
{#if message.timestamp}
<div class="max-w-[80%]">
- <ChatMessageActionIcons actionsPosition="right" justify="end" role={MessageRole.USER} />
+ <ChatMessageActionIcons actionsPosition="right" justify="end" />
</div>
{/if}
{/if}
diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageUser/ChatMessageUser.svelte b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageUser/ChatMessageUser.svelte
index 8be39f892..c2baf67a8 100644
--- a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageUser/ChatMessageUser.svelte
+++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageUser/ChatMessageUser.svelte
@@ -6,7 +6,7 @@
ChatMessageUserBubble
} from '$lib/components/app/chat';
import { getChatMessageEditContext } from '$lib/contexts';
- import { ChatMessageStatisticsMode, MessageRole } from '$lib/enums';
+ import { ChatMessageStatisticsMode } from '$lib/enums';
import { useProcessingState } from '$lib/hooks/use-processing-state.svelte';
import { chatStore, settingsStore } from '$lib/stores';
@@ -29,7 +29,11 @@
const processingState = useProcessingState();
const currentConfig = $derived(settingsStore.config);
- const isActivelyProcessing = $derived(isLastUserMessage && chatStore.isLoading);
+ // A compaction summary reports its own progress on the compaction row, so
+ // the summarized turn does not show live reading stats while it runs.
+ const isActivelyProcessing = $derived(
+ isLastUserMessage && chatStore.isLoading && !chatStore.isCompacting(message.convId)
+ );
// For agentic turns, prefer the cumulative agentic.llm totals over per-call timings.
let storedReadingStats = $derived.by(() => {
@@ -107,7 +111,7 @@
{#if message.timestamp}
<div class="max-w-[80%]">
- <ChatMessageActionIcons actionsPosition="right" justify="end" role={MessageRole.USER} />
+ <ChatMessageActionIcons actionsPosition="right" justify="end" />
</div>
{/if}
{/if}
diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessageActions/ChatMessageActionIcons/ChatMessageActionIcons.svelte b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessageActions/ChatMessageActionIcons/ChatMessageActionIcons.svelte
index 68ec5c846..041841d34 100644
--- a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessageActions/ChatMessageActionIcons/ChatMessageActionIcons.svelte
+++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessageActions/ChatMessageActionIcons/ChatMessageActionIcons.svelte
@@ -10,11 +10,9 @@
import Label from '$lib/components/ui/label/label.svelte';
import { Switch } from '$lib/components/ui/switch';
import { getChatMessageActionsContext, getChatMessageEditContext } from '$lib/contexts';
- import { MessageRole } from '$lib/enums';
import { conversationsStore } from '$lib/stores';
interface Props {
- role: MessageRole.USER | MessageRole.ASSISTANT;
justify: 'start' | 'end';
actionsPosition: 'left' | 'right';
onRegenerate?: () => void;
@@ -31,7 +29,6 @@
onRawOutputToggle,
onRegenerate,
rawOutputEnabled = false,
- role,
showRawOutputSwitch = false
}: Props = $props();
@@ -81,11 +78,11 @@
<ActionIcon icon={Edit} onclick={editCtx.startEdit} tooltip="Edit" />
- {#if role === MessageRole.ASSISTANT && onRegenerate}
+ {#if onRegenerate}
<ActionIcon icon={RefreshCw} onclick={() => onRegenerate()} tooltip="Regenerate" />
{/if}
- {#if role === MessageRole.ASSISTANT && onContinue}
+ {#if onContinue}
<ActionIcon icon={ArrowRight} onclick={onContinue} tooltip="Continue" />
{/if}
diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessages.svelte b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessages.svelte
index e7acb9cc6..25ea0b2bd 100644
--- a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessages.svelte
+++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessages.svelte
@@ -14,11 +14,10 @@
interface Props {
messages?: DatabaseMessage[];
- onUserAction?: () => void;
onMessagesReady?: (messageCount: number) => void;
}
- let { messages = [], onMessagesReady, onUserAction }: Props = $props();
+ let { messages = [], onMessagesReady }: Props = $props();
let allConversationMessages = $state<DatabaseMessage[]>([]);
@@ -26,7 +25,6 @@
const chatActions: ChatMessageActions = {
continueAssistantMessage: async (message: DatabaseMessage) => {
- onUserAction?.();
await chatStore.continueAssistantMessage(message.id);
refreshAllMessages();
},
@@ -52,7 +50,6 @@
newContent: string,
newExtras?: DatabaseMessageExtra[]
) => {
- onUserAction?.();
// in-place edit: the store already updated activeMessages and no
// branch is created, so sibling info stays valid without a refetch
await chatStore.editUserMessagePreserveResponses(message.id, newContent, newExtras);
@@ -63,7 +60,6 @@
newContent: string,
newExtras?: DatabaseMessageExtra[]
) => {
- onUserAction?.();
await chatStore.editMessageWithBranching(message.id, newContent, newExtras);
refreshAllMessages();
},
@@ -73,7 +69,6 @@
newContent: string,
shouldBranch: boolean
) => {
- onUserAction?.();
await chatStore.editAssistantMessage(message.id, newContent, shouldBranch);
// only a branch changes sibling info; an in-place edit already
@@ -92,8 +87,12 @@
await conversationsStore.navigateToSibling(siblingId);
},
+ regenerateCompaction: async (message: DatabaseMessage) => {
+ await chatStore.regenerateCompaction(message.id);
+ refreshAllMessages();
+ },
+
regenerateWithBranching: async (message: DatabaseMessage, modelOverride?: string) => {
- onUserAction?.();
await chatStore.regenerateMessageWithBranching(message.id, modelOverride);
refreshAllMessages();
}
diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/LazyChatMessage.svelte b/tools/ui/src/lib/components/app/chat/ChatMessages/LazyChatMessage.svelte
index 4bcc66da3..a502df7b4 100644
--- a/tools/ui/src/lib/components/app/chat/ChatMessages/LazyChatMessage.svelte
+++ b/tools/ui/src/lib/components/app/chat/ChatMessages/LazyChatMessage.svelte
@@ -1,5 +1,6 @@
<script lang="ts">
import ChatMessage from './ChatMessage/ChatMessage.svelte';
+ import { MessageType } from '$lib/enums';
import { chatStore } from '$lib/stores';
import type { ChatMessageActions } from '$lib/types';
@@ -78,7 +79,8 @@
<div
bind:this={wrapperEl}
class:chat-message--eager={eager}
- class:chat-message--synthetic={Boolean(message.isSynthetic)}
+ class:chat-message--synthetic={Boolean(message.isSynthetic) &&
+ message.type !== MessageType.COMPACTION}
class="chat-message"
>
{#if shouldMount}
diff --git a/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreen.svelte b/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreen.svelte
index 542493ff0..b846417f7 100644
--- a/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreen.svelte
+++ b/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreen.svelte
@@ -21,12 +21,11 @@
CHAT_FORM_MOVE_TO_BOTTOM_OUT_MS,
ROUTES
} from '$lib/constants';
- import { createAutoScrollController } from '$lib/hooks/use-auto-scroll.svelte';
import { useChatScreenActiveModel } from '$lib/hooks/use-chat-screen-active-model.svelte';
import { useChatScreenDragAndDrop } from '$lib/hooks/use-chat-screen-drag-and-drop.svelte';
import { useChatScreenFileUpload } from '$lib/hooks/use-chat-screen-file-upload.svelte';
- import { useChatScreenScroll } from '$lib/hooks/use-chat-screen-scroll.svelte';
import { useKeyboardShortcuts } from '$lib/hooks/use-keyboard-shortcuts.svelte';
+ import { createStickToBottom } from '$lib/hooks/use-stick-to-bottom.svelte';
import {
chatStore,
conversationsStore,
@@ -35,16 +34,10 @@
settingsStore
} from '$lib/stores';
import { parseFilesToMessageExtras } from '$lib/utils/browser-only';
- import { onDestroy, onMount, tick, untrack } from 'svelte';
+ import { onMount, tick, untrack } from 'svelte';
let { showCenteredEmpty = false } = $props();
- let disableAutoScroll = $derived(
- Boolean(settingsStore.config.disableAutoScroll) || deviceStore.isMobile
- );
- let isMobileUserScrolledUp = $state(false);
- let mobileScrollDownHint = $state(false);
- let mobileScrollDownHintLockedUntil = $state(0);
let emptyFileNames = $state<string[]>([]);
let initialMessage = $state('');
let showDeleteDialog = $state(false);
@@ -70,8 +63,9 @@
return '0.5rem';
});
- const autoScroll = createAutoScrollController();
- const scroll = useChatScreenScroll(autoScroll);
+ // The page is the scroller: it follows new content while the reader sits at
+ // the bottom, and otherwise leaves their position alone.
+ const stick = createStickToBottom();
const activeModel = useChatScreenActiveModel();
const fileUpload = useChatScreenFileUpload({
activeModelId: () => activeModel.activeModelId,
@@ -92,19 +86,6 @@
}
});
- function handleMobileScroll() {
- if (!deviceStore.isMobile) return;
-
- const container = scroll.chatScrollContainer;
-
- if (!container) return;
-
- const distanceFromBottom =
- container.scrollHeight - container.clientHeight - container.scrollTop;
-
- isMobileUserScrolledUp = distanceFromBottom > 300;
- }
-
async function handleDeleteConfirm() {
const conversation = conversationsStore.activeConversation;
@@ -136,7 +117,8 @@
return false;
}
- handleSendLikeScroll();
+ // Sending re-arms following: the reply streams in under the message.
+ stick.scrollToBottom();
await chatStore.sendMessage(message, result?.extras);
@@ -145,12 +127,9 @@
let lastScrolledConversationId: string | null = null;
- // Lands at the bottom of a conversation the first time its messages
- // render, whether the route comes from another conversation, from a
- // non-conversation route or from a page refresh. Runs before the first
- // paint of the new rows, and the landing pin keeps the bottom in place
- // while the rows settle. Independent of the auto-scroll setting, which
- // only governs following a streaming response.
+ // Lands at the bottom of a conversation the first time its messages render.
+ // The resize observer keeps it there while the list settles (lazy rows,
+ // syntax highlighting, images) without polling for a stable height.
async function handleMessagesReady(messageCount: number) {
if (messageCount === 0) return;
@@ -160,10 +139,7 @@
lastScrolledConversationId = id;
await tick();
-
- if (conversationsStore.activeConversation?.id !== id) return;
-
- scroll.landAtBottom();
+ stick.scrollToBottom();
}
/**
@@ -214,53 +190,6 @@
if (isEmpty !== isFormCentered) untrack(() => void moveForm());
});
- function handleSendLikeScroll() {
- if (!deviceStore.isMobile) {
- autoScroll.enable();
- }
-
- setTimeout(() => {
- const container = scroll.chatScrollContainer;
-
- if (!container) return;
-
- // the bubble of the message just sent: the last user bubble, whether or
- // not the answer row below it has rendered yet
- const userBubbles = container.querySelectorAll<HTMLElement>(
- '.chat-message-user .chat-message-user-bubble'
- );
- const lastUserBubble = userBubbles[userBubbles.length - 1] ?? null;
-
- if (deviceStore.isMobile) {
- // Keep the last user message bubble just above the input on mobile
- const bubbleHeight = lastUserBubble?.scrollHeight ?? 0;
- const baseHeight = container.scrollHeight - innerHeight;
-
- container.scrollTo({
- behavior: 'smooth',
- top: bubbleHeight > 0 ? baseHeight - bubbleHeight : baseHeight
- });
- } else if (lastUserBubble) {
- // On desktop, place the last user message near the top of the viewport
- const topPadding = 24;
- const bubbleRect = lastUserBubble.getBoundingClientRect();
-
- container.scrollTo({
- behavior: 'smooth',
- top: Math.max(0, container.scrollTop + bubbleRect.top - topPadding)
- });
- } else {
- autoScroll.scrollToBottom();
- }
- }, 100);
-
- if (deviceStore.isMobile) {
- autoScroll.setDisabled(disableAutoScroll);
- mobileScrollDownHint = true;
- mobileScrollDownHintLockedUntil = Date.now() + 500;
- }
- }
-
function handleErrorDialogOpenChange(open: boolean) {
if (!open) {
chatStore.dismissErrorDialog();
@@ -276,15 +205,13 @@
}
$effect(() => {
- const shouldDisableAutoScroll =
- settingsStore.config.disableAutoScroll ||
- (deviceStore.isMobile && isCurrentConversationLoading);
+ stick.setEnabled(!settingsStore.config.disableAutoScroll);
+ });
- autoScroll.setDisabled(shouldDisableAutoScroll);
+ $effect(() => {
+ stick.attach(document.documentElement, document.body);
- if (!shouldDisableAutoScroll) {
- autoScroll.enable();
- }
+ return () => stick.detach();
});
onMount(() => {
@@ -294,39 +221,14 @@
initialMessage = pendingDraft.message;
fileUpload.uploadedFiles = pendingDraft.files;
}
-
- autoScroll.startObserving();
-
- if (!disableAutoScroll) {
- autoScroll.enable();
- }
-
- if (deviceStore.isMobile && isCurrentConversationLoading) {
- mobileScrollDownHint = true;
- mobileScrollDownHintLockedUntil = Date.now() + 500;
- }
-
- handleMobileScroll();
});
-
- onDestroy(() => autoScroll.destroy());
</script>
{#if dragAndDrop.isDragOver}
<ChatScreenDragOverlay />
{/if}
-<svelte:window
- onkeydown={handleKeydown}
- onscroll={(e) => {
- scroll.handleScroll(e);
- handleMobileScroll();
-
- if (e.isTrusted && Date.now() > mobileScrollDownHintLockedUntil) {
- mobileScrollDownHint = false;
- }
- }}
-/>
+<svelte:window onkeydown={handleKeydown} onscroll={() => stick.handleScroll()} />
{#if isServerLoading}
<ServerLoadingSplash />
@@ -344,9 +246,6 @@
<ChatMessages
messages={conversationsStore.activeMessages}
onMessagesReady={handleMessagesReady}
- onUserAction={() => {
- handleSendLikeScroll();
- }}
/>
{/if}
@@ -379,16 +278,8 @@
{/if}
<div class="pointer-events-none flex flex-col gap-6 items-center w-full">
- {#if (deviceStore.isMobile ? mobileScrollDownHint || isMobileUserScrolledUp : autoScroll.userScrolledUp) && page.url.hash.includes(ROUTES.CHAT) && page.params.id}
- <ChatScreenActionScrollDown
- onclick={() => {
- mobileScrollDownHint = false;
- scroll.chatScrollContainer?.scrollTo({
- behavior: 'smooth',
- top: scroll.chatScrollContainer.scrollHeight
- });
- }}
- />
+ {#if !stick.atBottom && page.url.hash.includes(ROUTES.CHAT) && page.params.id}
+ <ChatScreenActionScrollDown onclick={() => stick.scrollToBottom('smooth')} />
{/if}
</div>
diff --git a/tools/ui/src/lib/components/app/chat/index.ts b/tools/ui/src/lib/components/app/chat/index.ts
index e5c7e1c6b..aa1101cf5 100644
--- a/tools/ui/src/lib/components/app/chat/index.ts
+++ b/tools/ui/src/lib/components/app/chat/index.ts
@@ -412,10 +412,7 @@ export { default as ChatFormPickers } from './ChatForm/ChatFormPickers/ChatFormP
*
* @example
* ```svelte
- * <ChatMessages
- * messages={activeMessages()}
- * onUserAction={resetAutoScroll}
- * />
+ * <ChatMessages messages={activeMessages()} onMessagesReady={handleReady} />
* ```
*/
export { default as ChatMessages } from './ChatMessages/ChatMessages.svelte';
@@ -549,6 +546,13 @@ export { default as ChatMessageCwdChange } from './ChatMessages/ChatMessage/Chat
*/
export { default as ChatMessageSynthetic } from './ChatMessages/ChatMessage/ChatMessageSynthetic.svelte';
+/**
+ * Collapsible summary card for compaction nodes. Shows a shimmering pending
+ * header while the summary streams in, then the stored summary as markdown;
+ * later requests slice the branch at this node.
+ */
+export { default as ChatMessageCompaction } from './ChatMessages/ChatMessage/ChatMessageCompaction.svelte';
+
/**
* Formatted content display for MCP prompt messages. Renders the full prompt
* content with arguments in a readable format. Used within ChatMessageMcpPrompt
@@ -616,7 +620,7 @@ export { default as ChatMessageSystem } from './ChatMessages/ChatMessage/ChatMes
*
* **Architecture:**
* - Composes ChatMessages, ChatScreenForm, and dialogs
- * - Manages auto-scroll via `createAutoScrollController()` hook
+ * - Follows new content via `createStickToBottom()` while the reader is at the bottom
* - Handles file upload pipeline (validation → processing → state update)
* - Integrates with serverStore for loading/error/warning states
* - Tracks active model for modality validation (vision, audio)
diff --git a/tools/ui/src/lib/components/app/content/CollapsibleContentBlock.svelte b/tools/ui/src/lib/components/app/content/CollapsibleContentBlock.svelte
index ecdd75bda..5c6213589 100644
--- a/tools/ui/src/lib/components/app/content/CollapsibleContentBlock.svelte
+++ b/tools/ui/src/lib/components/app/content/CollapsibleContentBlock.svelte
@@ -1,5 +1,6 @@
<script lang="ts">
import ChevronDown from '@lucide/svelte/icons/chevron-down';
+ import CollapsibleRegion from '$lib/components/app/misc/CollapsibleRegion.svelte';
import * as Collapsible from '$lib/components/ui/collapsible/index.js';
import { cn } from '$lib/components/ui/utils';
import { ICON_CLASS_DEFAULT } from '$lib/constants';
@@ -16,6 +17,21 @@
titleSnippet?: Snippet;
subtitle?: string;
shimmerTitle?: boolean;
+ /**
+ * `divider` centers the toggle between two rules, for blocks that mark a
+ * section boundary in the conversation instead of decorating a message.
+ */
+ variant?: 'default' | 'divider';
+ /**
+ * Render the header only, without a chevron or a body, for a block that
+ * has nothing to show yet and turns into a collapsible once it has.
+ */
+ collapsible?: boolean;
+ /**
+ * Drop the reasoning-style rule and indent, for a body that spans the
+ * message width on its own.
+ */
+ flushBody?: boolean;
onToggle?: () => void;
children: Snippet;
}
@@ -23,6 +39,8 @@
let {
children,
class: className = '',
+ collapsible = true,
+ flushBody = false,
icon: IconComponent,
iconClass = ICON_CLASS_DEFAULT,
iconUrl = null,
@@ -31,7 +49,8 @@
shimmerTitle = false,
subtitle,
title = '',
- titleSnippet
+ titleSnippet,
+ variant = 'default'
}: Props = $props();
function hideBrokenIcon(event: Event) {
@@ -39,6 +58,60 @@
}
</script>
+{#snippet toggleIcon(layoutClass: string)}
+ {#if iconUrl}
+ <img
+ alt=""
+ class={cn('shrink-0 rounded-sm', layoutClass, iconClass)}
+ onerror={hideBrokenIcon}
+ src={iconUrl}
+ />
+ {:else if IconComponent}
+ <IconComponent class={cn('shrink-0 text-muted-foreground/60', layoutClass, iconClass)} />
+ {/if}
+{/snippet}
+
+{#snippet dividerRow()}
+ <span aria-hidden="true" class="h-px min-w-4 flex-1 bg-current opacity-25"></span>
+
+ <div class="flex min-w-0 items-center gap-1.5">
+ {@render toggleIcon('')}
+
+ {@render toggleTitle('text-xs whitespace-nowrap')}
+
+ {#if collapsible}
+ <ChevronDown
+ class={cn(
+ 'size-3.5 shrink-0 text-muted-foreground/60 transition-transform duration-150 ease-out',
+ open && 'rotate-180'
+ )}
+ />
+ {/if}
+ </div>
+
+ <span aria-hidden="true" class="h-px min-w-4 flex-1 bg-current opacity-25"></span>
+{/snippet}
+
+{#snippet toggleTitle(layoutClass: string)}
+ <span
+ class={cn(
+ 'min-w-0 font-medium',
+ layoutClass,
+ shimmerTitle ? 'shimmer-text' : 'text-foreground/80'
+ )}
+ >
+ {#if titleSnippet}
+ {@render titleSnippet()}
+ {:else}
+ {title}
+ {/if}
+ </span>
+
+ {#if subtitle}
+ <span class="text-xs italic text-muted-foreground/70">{subtitle}</span>
+ {/if}
+{/snippet}
+
<Collapsible.Root
class={cn('group/collapsible', 'my-0!', className)}
onOpenChange={(value) => {
@@ -47,62 +120,60 @@
}}
{open}
>
- <Collapsible.Trigger
- class={cn(
- 'flex w-full cursor-pointer items-start justify-between gap-2 text-left',
- 'py-1.5 pr-1'
- )}
- >
- <div class="flex min-w-0 items-start gap-2 text-muted-foreground">
- {#if iconUrl}
- <img
- alt=""
- class={cn('shrink-0 rounded-sm mt-0.75', iconClass)}
- onerror={hideBrokenIcon}
- src={iconUrl}
- />
- {:else if IconComponent}
- <IconComponent class={cn('shrink-0 text-muted-foreground/60 mt-0.75', iconClass)} />
- {/if}
-
- <span
- class={cn(
- 'min-w-0 overflow-x-auto text-sm font-medium',
- shimmerTitle ? 'shimmer-text' : 'text-foreground/80'
- )}
- >
- {#if titleSnippet}
- {@render titleSnippet()}
- {:else}
- {title}
- {/if}
- </span>
-
- {#if subtitle}
- <span class="text-xs italic text-muted-foreground/70">{subtitle}</span>
- {/if}
+ {#if variant === 'divider' && !collapsible}
+ <div class="flex w-full items-center gap-2 py-1.5 text-muted-foreground">
+ {@render dividerRow()}
</div>
+ {:else if variant === 'divider'}
+ <Collapsible.Trigger
+ class="flex w-full cursor-pointer items-center gap-2 py-1.5 text-muted-foreground"
+ >
+ {@render dividerRow()}
- <ChevronDown
+ <span class="sr-only">Toggle content</span>
+ </Collapsible.Trigger>
+ {:else}
+ <Collapsible.Trigger
class={cn(
- 'size-4 shrink-0 text-muted-foreground/60 transition-all duration-150 ease-out opacity-0 group-hover/collapsible:opacity-100 mt-0.75',
- open && 'rotate-180'
+ 'flex w-full cursor-pointer items-start justify-between gap-2 text-left',
+ 'py-1.5 pr-1'
)}
- />
+ >
+ <div class="flex min-w-0 items-start gap-2 text-muted-foreground">
+ {@render toggleIcon('mt-0.75')}
- <span class="sr-only">Toggle content</span>
- </Collapsible.Trigger>
-
- <Collapsible.Content>
- <!-- Collapsible.Content renders its children unconditionally and only sets
- `hidden`, so a closed block would keep re-rendering its whole body on
- every streamed token. Gate on `open` so collapsed content costs nothing. -->
- {#if open}
- <div class="pl-1.5 grid min-w-0" style="min-height: var(--min-message-height);">
- <div class="min-w-0 border-l border-muted-foreground/20 pl-4 pb-2 my-2">
- {@render children()}
- </div>
+ {@render toggleTitle('overflow-x-auto text-sm')}
</div>
- {/if}
- </Collapsible.Content>
+
+ <ChevronDown
+ class={cn(
+ 'size-4 shrink-0 text-muted-foreground/60 transition-all duration-150 ease-out opacity-0 group-hover/collapsible:opacity-100 mt-0.75',
+ open && 'rotate-180'
+ )}
+ />
+
+ <span class="sr-only">Toggle content</span>
+ </Collapsible.Trigger>
+ {/if}
+
+ {#if collapsible}
+ <!-- Content is force-mounted so the trigger keeps a real `aria-controls` target;
+ the region owns opening and closing. It animates the height instead of
+ snapping it, and unmounts the body once the collapse is over - a closed
+ block still costs nothing, which matters while tokens stream past it. -->
+ <Collapsible.Content forceMount>
+ <CollapsibleRegion {open}>
+ <div class="pl-1.5 grid min-w-0" style="min-height: var(--min-message-height);">
+ <div
+ class={cn(
+ 'min-w-0 pb-2 my-2',
+ !flushBody && 'border-l border-muted-foreground/20 pl-4'
+ )}
+ >
+ {@render children()}
+ </div>
+ </div>
+ </CollapsibleRegion>
+ </Collapsible.Content>
+ {/if}
</Collapsible.Root>
diff --git a/tools/ui/src/lib/components/app/content/MarkdownContent/MarkdownContent.svelte b/tools/ui/src/lib/components/app/content/MarkdownContent/MarkdownContent.svelte
index 895e9a80a..4d5d7c2b9 100644
--- a/tools/ui/src/lib/components/app/content/MarkdownContent/MarkdownContent.svelte
+++ b/tools/ui/src/lib/components/app/content/MarkdownContent/MarkdownContent.svelte
@@ -32,7 +32,7 @@
UI_DATA_ATTRS
} from '$lib/constants';
import { BooleanString, ColorMode, UrlProtocol } from '$lib/enums';
- import { createAutoScrollController } from '$lib/hooks/use-auto-scroll.svelte';
+ import { createStickToBottom } from '$lib/hooks/use-stick-to-bottom.svelte';
import { settingsStore } from '$lib/stores';
import type { DatabaseMessageExtra } from '$lib/types/database';
import {
@@ -123,8 +123,8 @@
let streamingCodeScrollContainer = $state<HTMLDivElement>();
- // Auto-scroll controller for streaming code block content
- const streamingAutoScroll = createAutoScrollController();
+ // Follows the code that streams into the fixed-height block below.
+ const streamingScroll = createStickToBottom();
let pendingMarkdown: string | null = null;
let isProcessing = false;
@@ -800,18 +800,16 @@
}
});
- // Auto-scroll for streaming code block
+ // Follow the streaming code block while the reader stays at its bottom; the
+ // resize observer does the pinning, so no polling for a stable height.
$effect(() => {
- streamingAutoScroll.setContainer(streamingCodeScrollContainer);
- });
+ streamingScroll.attach(streamingCodeScrollContainer);
- $effect(() => {
- streamingAutoScroll.updateInterval(incompleteCodeBlock !== null);
+ return () => streamingScroll.detach();
});
onDestroy(() => {
cleanupEventListeners();
- streamingAutoScroll.destroy();
});
</script>
@@ -905,7 +903,7 @@
<div
bind:this={streamingCodeScrollContainer}
class="streaming-code-scroll-container"
- onscroll={() => streamingAutoScroll.handleScroll()}
+ onscroll={() => streamingScroll.handleScroll()}
>
<pre class="streaming-code-pre"><code
class="hljs language-{incompleteCodeBlock.language || 'text'}"
diff --git a/tools/ui/src/lib/components/app/misc/CollapsibleRegion.svelte b/tools/ui/src/lib/components/app/misc/CollapsibleRegion.svelte
index edbb5f774..c1b3f7a89 100644
--- a/tools/ui/src/lib/components/app/misc/CollapsibleRegion.svelte
+++ b/tools/ui/src/lib/components/app/misc/CollapsibleRegion.svelte
@@ -1,4 +1,5 @@
<script lang="ts">
+ import { COLLAPSIBLE_TRANSITION_MS } from '$lib/constants';
import type { Snippet } from 'svelte';
interface Props {
@@ -8,9 +9,8 @@
let { children, open }: Props = $props();
- // rows stay mounted through the collapse transition so it can play
- const EXPAND_TRANSITION_MS = 200;
- // the initial state only: the effect below owns it afterwards
+ // rows stay mounted through the collapse transition so it can play; this is
+ // only the initial state, the effect below owns it afterwards
// svelte-ignore state_referenced_locally
let contentMounted = $state(open);
@@ -21,7 +21,7 @@
return;
}
- const timer = setTimeout(() => (contentMounted = false), EXPAND_TRANSITION_MS);
+ const timer = setTimeout(() => (contentMounted = false), COLLAPSIBLE_TRANSITION_MS);
return () => clearTimeout(timer);
});
diff --git a/tools/ui/src/lib/constants/auto-scroll.constants.ts b/tools/ui/src/lib/constants/auto-scroll.constants.ts
index 7e5948aec..61a0ae274 100644
--- a/tools/ui/src/lib/constants/auto-scroll.constants.ts
+++ b/tools/ui/src/lib/constants/auto-scroll.constants.ts
@@ -1,19 +1,8 @@
-export const AUTO_SCROLL_INTERVAL = 100;
-// How long after the reader's wheel, touch or scroll key an upward scroll
-// still counts as theirs. Scrolls with no such input (scroll anchoring,
-// blocks re-rendering, programmatic scrolls) never stop auto-scroll.
-export const USER_SCROLL_INTENT_MS = 250;
-// Conversation landing: the page keeps growing after the first bottom pin
-// (lazy rows mounting, content-visibility size realizations, syntax
-// highlight passes), so the pin holds until the page has not resized for
-// this long or the reader interacts.
-export const LANDING_QUIET_MS = 1000;
// Rows at the end of the list that mount eagerly, so the first painted
// frame of a conversation already shows real content at the bottom.
export const LANDING_EAGER_ROWS = 8;
-// Chat main view: tight threshold because scroll-here events come from
-// discrete assistant-message appends.
-export const AUTO_SCROLL_AT_BOTTOM_THRESHOLD = 10;
+// Nested scroll areas that follow their own content while it streams. The page
+// itself is driven by `createStickToBottom()`, which needs no tuning knobs.
// Reasoning block: stickier because reasoning fires many small
// incremental DOM writes that easily drift a few pixels off bottom.
export const REASONING_SCROLL_AT_BOTTOM_THRESHOLD_PX = 64;
diff --git a/tools/ui/src/lib/constants/compaction.constants.ts b/tools/ui/src/lib/constants/compaction.constants.ts
new file mode 100644
index 000000000..5e0a80fb1
--- /dev/null
+++ b/tools/ui/src/lib/constants/compaction.constants.ts
@@ -0,0 +1,21 @@
+/* Conversation compaction constants */
+import { CONVERSATION_ID_SEPARATOR } from './stream.constants';
+
+const SUMMARY_PLACEHOLDER = '{{SUMMARY}}';
+
+export const COMPACTION = {
+ CONTEXT_TEMPLATE: `This conversation was compacted. Summary of the earlier exchange:\n\n${SUMMARY_PLACEHOLDER}`,
+ // Steps a compaction keeps as written: a user message, or an assistant
+ // message with its tool results.
+ DEFAULT_PRESERVED_STEPS: 10,
+ DEFAULT_PROMPT:
+ 'Summarize this conversation so it can seamlessly continue in a fresh context. Capture: the overall goal, key facts and decisions, the current state of any ongoing work (including code, file names, and data discussed), and what remains to be done. Write the summary as dense prose addressed to the assistant that will resume the conversation. Return ONLY the summary, nothing else.',
+ // Why the model stops once its output limit cut the answer.
+ FINISH_CUT: 'length',
+ // Server stream sessions are matched by exact id or by the id::* prefix,
+ // so a prefixed identity keeps the summary stream inside the visibility
+ // kick and byte-offset resume machinery while the reload probe for the
+ // bare conversation id can never reattach it as a chat turn.
+ STREAM_ID_PREFIX: `compact${CONVERSATION_ID_SEPARATOR}`,
+ SUMMARY_PLACEHOLDER
+} as const;
diff --git a/tools/ui/src/lib/constants/icons.constants.ts b/tools/ui/src/lib/constants/icons.constants.ts
index 8ac041c64..a8bd3e331 100644
--- a/tools/ui/src/lib/constants/icons.constants.ts
+++ b/tools/ui/src/lib/constants/icons.constants.ts
@@ -11,6 +11,7 @@ import {
File as FileIcon,
FileText as FileTextIcon,
FolderOpen as CwdCommandIcon,
+ FoldVertical as CompactCommandIcon,
Image as ImageIcon,
Lightbulb as ReasoningIcon,
Loader2 as LoadingIcon,
@@ -104,6 +105,7 @@ export const CAPABILITY_FLAG_KEYS: Record<ModelCapability, keyof ModelCapabiliti
/** Icons of the slash-command picker, one per chat-form command. */
export const CHAT_FORM_COMMAND_ICONS: Record<ChatFormCommandAction, Component> = {
+ [ChatFormCommandAction.COMPACT]: CompactCommandIcon,
[ChatFormCommandAction.CWD]: CwdCommandIcon,
[ChatFormCommandAction.MODEL]: MODEL_ICON,
[ChatFormCommandAction.PROMPT]: PromptCommandIcon
diff --git a/tools/ui/src/lib/constants/index.ts b/tools/ui/src/lib/constants/index.ts
index afbad3479..ac4f47b35 100644
--- a/tools/ui/src/lib/constants/index.ts
+++ b/tools/ui/src/lib/constants/index.ts
@@ -22,6 +22,7 @@ export * from './tool-ui.constants';
export * from './cache.constants';
export * from './chat-form.constants';
export * from './cli-flags.constants';
+export * from './compaction.constants';
export * from './code-block.constants';
export * from './context-keys.constants';
export * from './control-actions.constants';
diff --git a/tools/ui/src/lib/constants/settings-keys.constants.ts b/tools/ui/src/lib/constants/settings-keys.constants.ts
index 0dcca2ace..8ba01e9df 100644
--- a/tools/ui/src/lib/constants/settings-keys.constants.ts
+++ b/tools/ui/src/lib/constants/settings-keys.constants.ts
@@ -11,6 +11,8 @@ export const SETTINGS_KEYS = {
API_KEY: 'apiKey',
AUTO_MIC_ON_EMPTY: 'autoMicOnEmpty',
BACKEND_SAMPLING: 'backend_sampling',
+ COMPACTION_PRESERVED_STEPS: 'compactionPreservedSteps',
+ COMPACTION_PROMPT: 'compactionPrompt',
CONVERSATION_TABS: 'conversationTabs',
COPY_TEXT_ATTACHMENTS_AS_PLAIN_TEXT: 'copyTextAttachmentsAsPlainText',
CUSTOM_CSS: 'customCss',
diff --git a/tools/ui/src/lib/constants/settings.constants.ts b/tools/ui/src/lib/constants/settings.constants.ts
index 4c0d759f6..f275f9adf 100644
--- a/tools/ui/src/lib/constants/settings.constants.ts
+++ b/tools/ui/src/lib/constants/settings.constants.ts
@@ -1,5 +1,6 @@
import { DEFAULT_AGENTIC_CONFIG } from './agentic.constants';
import { CLI_FLAGS } from './cli-flags.constants';
+import { COMPACTION } from './compaction.constants';
import { DEFAULT_MCP_CONFIG } from './mcp.constants';
import { SETTINGS_KEYS } from './settings-keys.constants';
import { TITLE_GENERATION } from './title-generation.constants';
@@ -176,6 +177,21 @@ export const SETTINGS_REGISTRY: SettingsSectionEntry[] = [
standaloneField: false,
type: SettingsFieldType.CHECKBOX
},
+ {
+ defaultValue: COMPACTION.DEFAULT_PROMPT,
+ help: 'Optional override for the instruction sent to the model when compacting a conversation.',
+ key: SETTINGS_KEYS.COMPACTION_PROMPT,
+ label: 'Compaction prompt',
+ type: SettingsFieldType.TEXTAREA
+ },
+ {
+ defaultValue: COMPACTION.DEFAULT_PRESERVED_STEPS,
+ help: 'Last steps a compaction keeps as written after the summary: a user message, or an assistant message with its tool results.',
+ isPositiveInteger: true,
+ key: SETTINGS_KEYS.COMPACTION_PRESERVED_STEPS,
+ label: 'Compaction preserved steps',
+ type: SettingsFieldType.INPUT
+ },
{
defaultValue: false,
help: 'When copying a message with text attachments, combine them into a single plain text string instead of a special format that can be pasted back as attachments.',
@@ -264,7 +280,7 @@ export const SETTINGS_REGISTRY: SettingsSectionEntry[] = [
},
{
defaultValue: false,
- help: 'Disable automatic scrolling while messages stream so you can control the viewport position manually.',
+ help: 'Stop the view from following new content while messages stream, so you control the viewport position yourself.',
key: SETTINGS_KEYS.DISABLE_AUTO_SCROLL,
label: 'Disable automatic scroll',
type: SettingsFieldType.CHECKBOX
diff --git a/tools/ui/src/lib/constants/ui.constants.ts b/tools/ui/src/lib/constants/ui.constants.ts
index 414168aad..2212a3e46 100644
--- a/tools/ui/src/lib/constants/ui.constants.ts
+++ b/tools/ui/src/lib/constants/ui.constants.ts
@@ -8,6 +8,9 @@ import type { DesktopIconStripItem } from '$lib/types';
/** Delay before a copied affordance resets from its check to the copy icon. */
export const COPY_FEEDBACK_RESET_MS = 1500;
+/** How long a collapsible block's body takes to expand or collapse. */
+export const COLLAPSIBLE_TRANSITION_MS = 200;
+
export const FORK_TREE_DEPTH_PADDING = 8;
export const SYSTEM_MESSAGE_PLACEHOLDER = 'System message';
diff --git a/tools/ui/src/lib/enums/chat.enums.ts b/tools/ui/src/lib/enums/chat.enums.ts
index 6dcede2a5..6bd63b6b7 100644
--- a/tools/ui/src/lib/enums/chat.enums.ts
+++ b/tools/ui/src/lib/enums/chat.enums.ts
@@ -42,6 +42,7 @@ export enum MessageRole {
* Message types for different content kinds.
*/
export enum MessageType {
+ COMPACTION = 'compaction',
ROOT = 'root',
SYSTEM = 'system',
TEXT = 'text',
@@ -80,6 +81,7 @@ export enum PdfViewMode {
}
export enum ChatFormCommandAction {
+ COMPACT = 'compact',
CWD = 'cwd',
MODEL = 'model',
PROMPT = 'prompt'
diff --git a/tools/ui/src/lib/hooks/use-auto-scroll.svelte.ts b/tools/ui/src/lib/hooks/use-auto-scroll.svelte.ts
deleted file mode 100644
index 319ea22ed..000000000
--- a/tools/ui/src/lib/hooks/use-auto-scroll.svelte.ts
+++ /dev/null
@@ -1,298 +0,0 @@
-import {
- AUTO_SCROLL_AT_BOTTOM_THRESHOLD,
- AUTO_SCROLL_INTERVAL,
- USER_SCROLL_INTENT_MS
-} from '$lib/constants';
-import { KeyboardKey } from '$lib/enums';
-
-const SCROLL_UP_KEYS: string[] = [KeyboardKey.ARROW_UP, KeyboardKey.HOME, KeyboardKey.PAGE_UP];
-
-export interface AutoScrollOptions {
- disabled?: boolean;
-}
-
-/**
- * Creates an auto-scroll controller for a scrollable container.
- *
- * Features:
- * - Auto-scrolls to bottom during streaming/loading
- * - Stops auto-scroll when user manually scrolls up
- * - Resumes auto-scroll when user scrolls back to bottom
- *
- * Only the reader's own input (wheel, touch, scroll keys, scrollbar drag)
- * counts as scrolling up: the page also moves up on its own while a
- * response streams (scroll anchoring, blocks re-rendering shorter, the
- * send animation), and that must not stop following the response.
- */
-export class AutoScrollController {
- private _autoScrollEnabled = $state(true);
- private _container: HTMLElement | undefined;
- private _disabled: boolean;
- private _draggingScrollbar = false;
- private _lastScrollTop = $state(0);
- private _lastUserIntentAt = 0;
- private _mutationObserver: MutationObserver | null = null;
- private _observerEnabled = false;
- private _rafPending = false;
- private _scrollInterval: ReturnType<typeof setInterval> | undefined;
- private _unbindUserIntent: (() => void) | null = null;
- private _userScrolledUp = $state(false);
-
- get autoScrollEnabled(): boolean {
- return this._autoScrollEnabled;
- }
-
- get userScrolledUp(): boolean {
- return this._userScrolledUp;
- }
-
- constructor(options: AutoScrollOptions = {}) {
- this._disabled = options.disabled ?? false;
- }
-
- /**
- * Cleans up resources. Call this in onDestroy or when the component unmounts.
- */
- destroy(): void {
- this.stopInterval();
- this._doStopObserving();
- this._unbindUserIntent?.();
- this._unbindUserIntent = null;
- }
-
- /**
- * Enables auto-scroll (e.g., when user sends a message).
- */
- enable(): void {
- if (this._disabled) return;
-
- this._userScrolledUp = false;
- this._autoScrollEnabled = true;
- }
-
- /**
- * Handles scroll events to detect user scroll direction and toggle auto-scroll.
- */
- handleScroll(): void {
- if (this._disabled || !this._container) return;
-
- const { clientHeight, scrollHeight, scrollTop } = this._container;
- const distanceFromBottom = scrollHeight - clientHeight - scrollTop;
- const isScrollingUp = scrollTop < this._lastScrollTop;
- const isAtBottom = distanceFromBottom < AUTO_SCROLL_AT_BOTTOM_THRESHOLD;
- const isUserScrolling =
- this._draggingScrollbar || performance.now() - this._lastUserIntentAt < USER_SCROLL_INTENT_MS;
-
- if (isScrollingUp && !isAtBottom && isUserScrolling) {
- this._userScrolledUp = true;
- this._autoScrollEnabled = false;
- } else if (isAtBottom && this._userScrolledUp) {
- this._userScrolledUp = false;
- this._autoScrollEnabled = true;
- }
-
- this._lastScrollTop = scrollTop;
- }
-
- /**
- * Resets scroll state when switching conversations.
- */
- resetScrollState(): void {
- this._userScrolledUp = false;
- this._autoScrollEnabled = !this._disabled;
-
- if (this._container) {
- this._lastScrollTop = this._container.scrollTop;
- }
- }
-
- /**
- * Scrolls the container to the bottom instantly.
- */
- scrollToBottom(): void {
- if (this._disabled || !this._container) return;
-
- this._container.scrollTop = this._container.scrollHeight;
- }
-
- /**
- * Binds the controller to a scrollable container element.
- */
- setContainer(container: HTMLElement | undefined): void {
- this._doStopObserving();
- this._unbindUserIntent?.();
- this._unbindUserIntent = null;
- this._container = container;
-
- if (container) {
- this._unbindUserIntent = this._bindUserIntent(container);
- }
-
- if (this._observerEnabled && container && !this._disabled) {
- this._doStartObserving();
- }
- }
-
- /**
- * Updates the disabled state.
- */
- setDisabled(disabled: boolean): void {
- if (this._disabled === disabled) return;
-
- this._disabled = disabled;
-
- if (disabled) {
- this._autoScrollEnabled = false;
- this.stopInterval();
- this._doStopObserving();
- } else if (this._observerEnabled && this._container && !this._mutationObserver) {
- this._doStartObserving();
- }
- }
-
- /**
- * Starts the auto-scroll interval for continuous scrolling during streaming.
- */
- startInterval(): void {
- if (this._disabled || this._scrollInterval) return;
-
- this._scrollInterval = setInterval(() => {
- this.scrollToBottom();
- }, AUTO_SCROLL_INTERVAL);
- }
-
- /**
- * Starts a MutationObserver on the container that auto-scrolls to bottom
- * on content changes. More responsive than interval-based polling.
- */
- startObserving(): void {
- this._observerEnabled = true;
-
- if (this._container && !this._disabled && !this._mutationObserver) {
- this._doStartObserving();
- }
- }
-
- /**
- * Stops the auto-scroll interval.
- */
- stopInterval(): void {
- if (this._scrollInterval) {
- clearInterval(this._scrollInterval);
- this._scrollInterval = undefined;
- }
- }
-
- /**
- * Stops the MutationObserver.
- */
- stopObserving(): void {
- this._observerEnabled = false;
- this._doStopObserving();
- }
-
- /**
- * Updates the auto-scroll interval based on streaming state.
- * Call this in a $effect to automatically manage the interval.
- */
- updateInterval(isStreaming: boolean): void {
- if (this._disabled) {
- this.stopInterval();
-
- return;
- }
-
- if (isStreaming && this._autoScrollEnabled) {
- if (!this._scrollInterval) {
- this.startInterval();
- }
- } else {
- this.stopInterval();
- }
- }
-
- /**
- * Records the reader's own scroll input, so `handleScroll` can tell their
- * scrolling from the page moving on its own.
- */
- private _bindUserIntent(container: HTMLElement): () => void {
- const isPage = container === document.documentElement;
- const target: HTMLElement | Window = isPage ? window : container;
- const markIntent = () => {
- this._lastUserIntentAt = performance.now();
- };
- const onWheel = (event: WheelEvent) => {
- if (event.deltaY < 0) markIntent();
- };
- const onKeyDown = (event: KeyboardEvent) => {
- if (
- SCROLL_UP_KEYS.includes(event.key) ||
- (event.key === KeyboardKey.SPACE && event.shiftKey)
- ) {
- markIntent();
- }
- };
- // a press right of the content box lands on the scrollbar
- const onPointerDown = (event: PointerEvent) => {
- const left = isPage ? 0 : container.getBoundingClientRect().left;
-
- this._draggingScrollbar = event.clientX >= left + container.clientWidth;
- };
- const onPointerUp = () => {
- this._draggingScrollbar = false;
- };
-
- target.addEventListener('wheel', onWheel as EventListener, { passive: true });
- target.addEventListener('touchmove', markIntent, { passive: true });
- target.addEventListener('keydown', onKeyDown as EventListener);
- target.addEventListener('pointerdown', onPointerDown as EventListener);
- window.addEventListener('pointerup', onPointerUp);
-
- return () => {
- target.removeEventListener('wheel', onWheel as EventListener);
- target.removeEventListener('touchmove', markIntent);
- target.removeEventListener('keydown', onKeyDown as EventListener);
- target.removeEventListener('pointerdown', onPointerDown as EventListener);
- window.removeEventListener('pointerup', onPointerUp);
- };
- }
-
- private _doStartObserving(): void {
- if (!this._container || this._mutationObserver) return;
-
- this._mutationObserver = new MutationObserver(() => {
- if (!this._autoScrollEnabled || this._rafPending) return;
-
- this._rafPending = true;
- requestAnimationFrame(() => {
- this._rafPending = false;
-
- if (this._autoScrollEnabled && this._container) {
- this._container.scrollTop = this._container.scrollHeight;
- }
- });
- });
-
- this._mutationObserver.observe(this._container, {
- characterData: true,
- childList: true,
- subtree: true
- });
- }
-
- private _doStopObserving(): void {
- if (this._mutationObserver) {
- this._mutationObserver.disconnect();
- this._mutationObserver = null;
- }
-
- this._rafPending = false;
- }
-}
-
-/**
- * Creates a new AutoScrollController instance.
- */
-export function createAutoScrollController(options: AutoScrollOptions = {}): AutoScrollController {
- return new AutoScrollController(options);
-}
diff --git a/tools/ui/src/lib/hooks/use-chat-form-pickers.svelte.ts b/tools/ui/src/lib/hooks/use-chat-form-pickers.svelte.ts
index 986cc46c7..8698fd4ef 100644
--- a/tools/ui/src/lib/hooks/use-chat-form-pickers.svelte.ts
+++ b/tools/ui/src/lib/hooks/use-chat-form-pickers.svelte.ts
@@ -26,6 +26,10 @@ export interface UseChatFormPickersOptions {
hasPrompts: () => boolean;
/** Gates `/cwd`. */
hasCwdTools: () => boolean;
+ /** Gates `/compact`. */
+ canCompact: () => boolean;
+ /** Runs `/compact`. */
+ onCompact: () => void;
getCwd: () => string | null;
/** Mention search fallback scope. */
getServerHome: () => string | null;
@@ -59,6 +63,7 @@ export function useChatFormPickers(opts: UseChatFormPickersOptions) {
const mentionScopePath = $derived(opts.getCwd() ?? opts.getServerHome() ?? null);
const availableCommands = $derived(
getChatCommands({
+ canCompact: opts.canCompact,
hasCwdTools: opts.hasCwdTools,
hasPrompts: opts.hasPrompts,
showModelSelector: opts.getShowModelSelector()
@@ -74,6 +79,12 @@ export function useChatFormPickers(opts: UseChatFormPickersOptions) {
commandQuery = '';
switch (command.action) {
+ case ChatFormCommandAction.COMPACT:
+ isWorkingDirectoryPickerOpen = false;
+ opts.setValue('');
+ opts.onCompact();
+
+ break;
case ChatFormCommandAction.PROMPT:
isWorkingDirectoryPickerOpen = false;
opts.setValue('');
diff --git a/tools/ui/src/lib/hooks/use-chat-screen-scroll.svelte.ts b/tools/ui/src/lib/hooks/use-chat-screen-scroll.svelte.ts
deleted file mode 100644
index faf06bb8a..000000000
--- a/tools/ui/src/lib/hooks/use-chat-screen-scroll.svelte.ts
+++ /dev/null
@@ -1,109 +0,0 @@
-/**
- * Scroll container binding and navigation guard for the ChatScreen.
- *
- * Binds the `AutoScrollController` to `document.documentElement`, exposes
- * the container for programmatic scrolling, and flags an `isNavigating`
- * window during route changes so the controller can reset without its
- * scroll handler seeing spurious events from layout shifts.
- */
-
-import type { AutoScrollController } from './use-auto-scroll.svelte';
-import { afterNavigate, beforeNavigate, disableScrollHandling, onNavigate } from '$app/navigation';
-import { LANDING_QUIET_MS } from '$lib/constants';
-import { onDestroy } from 'svelte';
-
-const LANDING_INTENT_EVENTS = ['keydown', 'pointerdown', 'touchstart', 'wheel'] as const;
-
-export function useChatScreenScroll(autoScroll: AutoScrollController) {
- let chatScrollContainer: HTMLElement | undefined = $state();
- let isNavigating = $state(false);
- let stopLanding: (() => void) | null = null;
-
- function handleScroll(event: UIEvent) {
- // Ignore scroll events caused by navigation layout changes. Programmatic
- // and layout scrolls cannot stop auto-scroll: only the reader's own
- // input counts as scrolling up (see AutoScrollController.handleScroll).
- if (isNavigating || !event.isTrusted) return;
-
- autoScroll.handleScroll();
- }
-
- /**
- * Pins the page to its bottom while a freshly opened conversation settles.
- * ResizeObserver callbacks run after layout and before paint, so every
- * height change (lazy rows mounting, content-visibility realizing a row's
- * size, highlight passes) is corrected in the frame that caused it and no
- * frame is ever painted off the bottom. Stops on the reader's first input
- * or once the page has not resized for `LANDING_QUIET_MS`.
- */
- function landAtBottom() {
- stopLanding?.();
-
- const container = chatScrollContainer;
-
- if (!container) return;
-
- let quietTimer: ReturnType<typeof setTimeout> | undefined;
-
- const pin = () => {
- container.scrollTop = container.scrollHeight;
- clearTimeout(quietTimer);
- quietTimer = setTimeout(stop, LANDING_QUIET_MS);
- };
- const observer = new ResizeObserver(pin);
- const stop = () => {
- observer.disconnect();
- clearTimeout(quietTimer);
-
- for (const type of LANDING_INTENT_EVENTS) {
- window.removeEventListener(type, stop, { capture: true });
- }
-
- if (stopLanding === stop) stopLanding = null;
- };
-
- stopLanding = stop;
-
- for (const type of LANDING_INTENT_EVENTS) {
- window.addEventListener(type, stop, { capture: true, passive: true });
- }
-
- pin();
- observer.observe(document.body);
- }
-
- beforeNavigate(() => {
- isNavigating = true;
- stopLanding?.();
- autoScroll.resetScrollState();
- });
-
- // Opening a conversation lands at its bottom once its messages load, so
- // SvelteKit's own reset (or restore, on back/forward) would only move the
- // previous conversation, still on screen, to its top for a few frames.
- onNavigate((navigation) => {
- if (navigation.to?.params?.id) disableScrollHandling();
- });
-
- afterNavigate(() => {
- setTimeout(() => {
- isNavigating = false;
- autoScroll.resetScrollState();
- }, 10);
- });
-
- $effect(() => {
- chatScrollContainer = document.documentElement;
- autoScroll.setContainer(chatScrollContainer);
- });
-
- onDestroy(() => stopLanding?.());
-
- return {
- get chatScrollContainer() {
- return chatScrollContainer;
- },
- handleScroll,
- landAtBottom
- };
-}
diff --git a/tools/ui/src/lib/hooks/use-stick-to-bottom.svelte.ts b/tools/ui/src/lib/hooks/use-stick-to-bottom.svelte.ts
new file mode 100644
index 000000000..f4e201e9b
--- /dev/null
+++ b/tools/ui/src/lib/hooks/use-stick-to-bottom.svelte.ts
@@ -0,0 +1,116 @@
+/**
+ * Keeps a scroll container pinned to its bottom while content grows at the end.
+ *
+ * The rules are deliberately small, because the browser already does the hard
+ * part: content that grows below the viewport does not move the reader (scroll
+ * anchoring), so a block they expand or a late layout pass leaves the viewport
+ * exactly where it was. This only adds the one behaviour CSS cannot express -
+ * following new content while the reader sits at the bottom - and it stops the
+ * moment they scroll away from it.
+ */
+
+/** Distance from the bottom that still counts as being at the bottom. */
+const AT_BOTTOM_PX = 24;
+/**
+ * Growth this soon after a press is the reader's own doing (expanding a block,
+ * opening a preview), so it must not drag the viewport down with it.
+ */
+const INTERACTION_GRACE_MS = 400;
+
+export class StickToBottom {
+ /** Measured position, which drives the "scroll to bottom" affordance. */
+ atBottom = $state(true);
+ /** The reader is at the bottom, so new content should follow. */
+ stick = $state(true);
+
+ private container: HTMLElement | undefined;
+ private enabled = true;
+ private markPressed = (): void => {
+ this.pressedAt = performance.now();
+ };
+ private observer: ResizeObserver | null = null;
+
+ private pressedAt = 0;
+
+ /**
+ * Binds the scroller and the element whose growth should be followed.
+ * `content` defaults to the scroller's first child, which is what a panel
+ * with a single growing body needs.
+ */
+ attach(container: HTMLElement | undefined, content?: HTMLElement | undefined): void {
+ this.detach();
+ this.container = container;
+
+ const observed = content ?? container?.firstElementChild;
+
+ if (!container || !observed) return;
+
+ this.observer = new ResizeObserver(() => this.follow());
+ this.observer.observe(observed);
+ container.addEventListener('pointerdown', this.markPressed, {
+ capture: true,
+ passive: true
+ });
+
+ this.measure();
+ }
+
+ detach(): void {
+ this.observer?.disconnect();
+ this.observer = null;
+ this.container?.removeEventListener('pointerdown', this.markPressed, { capture: true });
+ this.container = undefined;
+ }
+
+ /** Reader scrolls are the only thing that re-arms or stops following. */
+ handleScroll(): void {
+ this.measure();
+ }
+
+ scrollToBottom(behavior: ScrollBehavior = 'auto'): void {
+ const el = this.container;
+
+ if (!el) return;
+
+ el.scrollTo({ behavior, top: el.scrollHeight });
+ this.stick = true;
+ this.atBottom = true;
+ }
+
+ setEnabled(enabled: boolean): void {
+ this.enabled = enabled;
+ }
+
+ private distanceFromBottom(el: HTMLElement): number {
+ return el.scrollHeight - el.clientHeight - el.scrollTop;
+ }
+
+ private follow(): void {
+ const el = this.container;
+
+ if (!el) return;
+
+ const pressedRecently = performance.now() - this.pressedAt < INTERACTION_GRACE_MS;
+
+ if (this.enabled && this.stick && !pressedRecently) {
+ el.scrollTop = el.scrollHeight;
+ this.atBottom = true;
+
+ return;
+ }
+
+ this.atBottom = this.distanceFromBottom(el) <= AT_BOTTOM_PX;
+ }
+
+ private measure(): void {
+ const el = this.container;
+
+ if (!el) return;
+
+ this.stick = this.atBottom = this.distanceFromBottom(el) <= AT_BOTTOM_PX;
+ }
+}
+
+export function createStickToBottom(): StickToBottom {
+ return new StickToBottom();
+}
diff --git a/tools/ui/src/lib/services/chat.service.ts b/tools/ui/src/lib/services/chat.service.ts
index b008b16db..a53bfef09 100644
--- a/tools/ui/src/lib/services/chat.service.ts
+++ b/tools/ui/src/lib/services/chat.service.ts
@@ -41,6 +41,7 @@ import type {
ApiChatMessageData,
ApiStreamSession
} from '$lib/types/api';
+import type { OpenAIToolDefinition } from '$lib/types/mcp';
import { isAbortError } from '$lib/utils/abort';
import { ApiError } from '$lib/utils/api-fetch';
import { getAuthHeaders, getJsonHeaders } from '$lib/utils/api-headers';
@@ -432,7 +433,8 @@ export class ChatService {
response: string,
reasoningContent?: string,
timings?: ChatMessageTimings,
- toolCalls?: string
+ toolCalls?: string,
+ finishReason?: string
) => void,
onError?: (error: Error) => void,
onReasoningChunk?: (chunk: string) => void,
@@ -476,6 +478,8 @@ export class ChatService {
let fullReasoningContent = '';
let aggregatedToolCalls: ApiChatCompletionToolCall[] = [];
let lastTimings: ChatMessageTimings | undefined;
+ // why the model stopped, length once the output limit cut the answer
+ let finishReason: string | undefined;
let streamFinished = false;
let modelEmitted = false;
let idEmitted = false;
@@ -625,6 +629,9 @@ export class ChatService {
try {
const parsed: ApiChatCompletionStreamChunk = JSON.parse(data);
const choice = parsed.choices?.[0];
+
+ if (choice?.finish_reason) finishReason = choice.finish_reason;
+
const content = choice?.delta?.content;
const reasoningContent = choice?.delta?.reasoning_content;
const toolCalls = choice?.delta?.tool_calls;
@@ -753,7 +760,8 @@ export class ChatService {
aggregatedContent,
fullReasoningContent || undefined,
lastTimings,
- finalToolCalls
+ finalToolCalls,
+ finishReason
);
}
} catch (error) {
@@ -835,14 +843,16 @@ export class ChatService {
/**
* Fire-and-forget request to pre-encode the conversation in the server's KV cache.
* Re-submits the full conversation with n_predict=0 so the server processes the prompt
- * without generating tokens, warming the cache for the next turn.
+ * without generating tokens, warming the cache for the next turn. The tools the next
+ * turn offers render ahead of the messages, so they go along to warm that very prefix.
*/
static async preEncode(
messages: ApiChatMessageData[] | (DatabaseMessage & { extra?: DatabaseMessageExtra[] })[],
model?: string | null,
excludeReasoning?: boolean,
- signal?: AbortSignal
- ): Promise<void> {
+ signal?: AbortSignal,
+ tools?: OpenAIToolDefinition[]
+ ): Promise<ChatMessageTimings | null> {
const normalizedMessages: ApiChatMessageData[] =
await ChatService.normalizeMessagesForApi(messages);
const requestBody: Record<string, unknown> = {
@@ -861,7 +871,8 @@ export class ChatService {
return mapped;
}),
n_predict: 0,
- stream: false
+ stream: false,
+ tools: tools && tools.length > 0 ? tools : undefined
};
if (model) {
@@ -869,17 +880,38 @@ export class ChatService {
}
try {
- await fetch(API_CHAT.COMPLETIONS, {
+ const response = await fetch(API_CHAT.COMPLETIONS, {
body: JSON.stringify(requestBody),
headers: getJsonHeaders(),
method: 'POST',
signal
});
+
+ if (!response.ok) return null;
+
+ const data = await response.json();
+
+ // llama.cpp attaches a timings object with the exact fresh and
+ // cached prompt token split; usage.prompt_tokens is the
+ // OpenAI-compatible total, kept as fallback.
+ if (typeof data?.timings?.prompt_n === 'number') {
+ return {
+ cache_n: data.timings.cache_n ?? 0,
+ predicted_n: 0,
+ prompt_n: data.timings.prompt_n
+ };
+ }
+
+ if (typeof data?.usage?.prompt_tokens === 'number') {
+ return { cache_n: 0, predicted_n: 0, prompt_n: data.usage.prompt_tokens };
+ }
} catch (error) {
if (!isAbortError(error)) {
console.warn('[ChatService] Pre-encode request failed:', error);
}
}
+
+ return null;
}
// probe the resume route status without consuming the stream: the SSE route has no HEAD,
@@ -1447,7 +1479,8 @@ export class ChatService {
response: string,
reasoningContent?: string,
timings?: ChatMessageTimings,
- toolCalls?: string
+ toolCalls?: string,
+ finishReason?: string
) => void,
onError?: (error: Error) => void,
onToolCallChunk?: (chunk: string) => void,
@@ -1493,7 +1526,13 @@ export class ChatService {
throw noResponseError;
}
- onComplete?.(content, reasoningContent, undefined, serializedToolCalls);
+ onComplete?.(
+ content,
+ reasoningContent,
+ undefined,
+ serializedToolCalls,
+ data.choices[0]?.finish_reason ?? undefined
+ );
return content;
} catch (error) {
diff --git a/tools/ui/src/lib/stores/agentic/index.svelte.ts b/tools/ui/src/lib/stores/agentic/index.svelte.ts
index 121ee7739..f22b6b954 100644
--- a/tools/ui/src/lib/stores/agentic/index.svelte.ts
+++ b/tools/ui/src/lib/stores/agentic/index.svelte.ts
@@ -53,6 +53,7 @@ import type {
AgenticFlowOptions,
AgenticMessage,
AgenticToolCallList,
+ AgenticToolPolicy,
SteeringMessage
} from '$lib/types/agentic';
import type {
@@ -73,6 +74,7 @@ import type {
DatabaseMessageExtraAudioFile,
DatabaseMessageExtraImageFile
} from '$lib/types/database';
+import type { OpenAIToolDefinition } from '$lib/types/mcp';
import {
executeBrowserInfoTool,
executeGetDatetimeTool,
@@ -293,6 +295,24 @@ class AgenticStore {
return this.sessions.get(conversationId)?.isRunning ?? false;
}
+ /**
+ * What a request of the chat sends under this tool policy: its messages
+ * as the agentic flow shapes them, and the tools it offers, none when the
+ * flow does not run. A request built from it shares the prefix the server
+ * cached for the conversation.
+ */
+ async requestFor(
+ messages: ApiChatMessageData[],
+ toolPolicy?: AgenticToolPolicy
+ ): Promise<{ messages: ApiChatMessageData[]; tools: OpenAIToolDefinition[] }> {
+ const tools = await this.toolsFor(toolPolicy);
+
+ return {
+ messages: tools.length > 0 ? (toAgenticMessages(messages) as ApiChatMessageData[]) : messages,
+ tools
+ };
+ }
+
resolveContinue(conversationId: string, shouldContinue: boolean): void {
this.gates.resolveContinue(conversationId, shouldContinue);
}
@@ -315,44 +335,15 @@ class AgenticStore {
// Clear any pending permissions/continue requests for this conversation when starting a new flow
this.gates.clear(conversationId);
- // Ensure server tools are fetched before checking if agentic is enabled.
- // A disabled /tools endpoint stays disabled for the life of the server,
- // so the tools panel is the only place that probes it again.
- if (
- toolsStore.serverTools.length === 0 &&
- !toolsStore.loading &&
- !toolsStore.isToolsEndpointUnreachable
- ) {
- await toolsStore.fetchServerTools();
- }
-
const agenticConfig = this.getConfig(settingsStore.config);
-
- if (!agenticConfig.enabled) return { handled: false };
-
- // callers without an explicit policy fall back to the global defaults
- const disabledTools = new Set(toolPolicy?.disabledTools ?? toolsStore.disabledTools);
- const disabledToolCategories = new Set(
- toolPolicy?.disabledToolCategories ?? toolsStore.disabledToolCategories
- );
- // initialize every settings-enabled server; tool collection filters by this
- // flow's policy, so switching policies never re-initializes connections
- const hasMcpServers = conversationsStore.preferences.policyEnabledServerIds().length > 0;
-
- if (hasMcpServers) {
- const initialized = await mcpStore.ensureInitialized();
-
- if (!initialized) {
- console.log('[AgenticStore] MCP not initialized');
- }
- }
-
- const tools = toolsStore.getEnabledToolsForLLM(disabledTools, disabledToolCategories);
+ const tools = await this.toolsFor(toolPolicy);
if (tools.length === 0) {
return { handled: false };
}
+ const hasMcpServers = conversationsStore.preferences.policyEnabledServerIds().length > 0;
+
console.log(`[AgenticStore] Starting agentic flow with ${tools.length} tools`);
const normalizedMessages: ApiChatMessageData[] =
@@ -1071,6 +1062,42 @@ class AgenticStore {
return JSON.parse(trimmed) as Record<string, unknown>;
}
+ /**
+ * The tools a flow under this policy offers the model, none when agentic
+ * is off. Server tools are fetched first: a disabled /tools endpoint stays
+ * disabled for the life of the server, so the tools panel is the only
+ * place that probes it again. Every settings-enabled MCP server is
+ * initialized, and the collection filters by the policy, so switching
+ * policies never re-initializes connections; a caller without a policy
+ * falls back to the global defaults.
+ */
+ private async toolsFor(toolPolicy?: AgenticToolPolicy): Promise<OpenAIToolDefinition[]> {
+ if (
+ toolsStore.serverTools.length === 0 &&
+ !toolsStore.loading &&
+ !toolsStore.isToolsEndpointUnreachable
+ ) {
+ await toolsStore.fetchServerTools();
+ }
+
+ if (!this.getConfig(settingsStore.config).enabled) return [];
+
+ const disabledTools = new Set(toolPolicy?.disabledTools ?? toolsStore.disabledTools);
+ const disabledToolCategories = new Set(
+ toolPolicy?.disabledToolCategories ?? toolsStore.disabledToolCategories
+ );
+
+ if (conversationsStore.preferences.policyEnabledServerIds().length > 0) {
+ const initialized = await mcpStore.ensureInitialized();
+
+ if (!initialized) {
+ console.log('[AgenticStore] MCP not initialized');
+ }
+ }
+
+ return toolsStore.getEnabledToolsForLLM(disabledTools, disabledToolCategories);
+ }
+
private updateSession(conversationId: string, update: Partial<AgenticSession>): void {
const session = this.getSession(conversationId);
diff --git a/tools/ui/src/lib/stores/chat/activity.svelte.ts b/tools/ui/src/lib/stores/chat/activity.svelte.ts
index cd4e0497b..de6fc2f06 100644
--- a/tools/ui/src/lib/stores/chat/activity.svelte.ts
+++ b/tools/ui/src/lib/stores/chat/activity.svelte.ts
@@ -16,14 +16,13 @@
* Composed under chatStore.activity; not exported from the stores barrel.
*/
-import { SvelteSet } from 'svelte/reactivity';
+import { SvelteMap, SvelteSet } from 'svelte/reactivity';
export class ChatActivityStore {
/** Convs this browser is piping a stream for (send, attach, resume-wait). */
private local = new SvelteSet<string>();
/** Convs the backend reports as having a running session (snapshot sync). */
private remote = new SvelteSet<string>();
-
/** Convs with any activity, the union the sidebar spinners render. */
loadingConvs = $derived.by(() => {
const out = new SvelteSet<string>(this.local);
@@ -33,6 +32,13 @@ export class ChatActivityStore {
return Array.from(out);
});
+ /**
+ * Convs whose in-flight run is a compaction summary, mapped to the node
+ * being summarized. That row reports the progress itself, so the chat
+ * turns it summarizes must not repeat the same indicator.
+ */
+ private compacting = new SvelteMap<string, string>();
+
/**
* Apply a backend snapshot of running sessions (mount / visibilitychange).
* Diffed so unchanged entries do not re-trigger reactivity.
@@ -47,6 +53,20 @@ export class ChatActivityStore {
for (const id of next) this.remote.add(id);
}
+ /** The conv's compaction summary ended, however it ended. */
+ endCompacting(convId: string): void {
+ this.compacting.delete(convId);
+ }
+
+ /** Node id of the summary running for the conv, or null when none runs. */
+ getCompactingNodeId(convId: string): string | null {
+ return this.compacting.get(convId) ?? null;
+ }
+
+ isCompacting(convId: string): boolean {
+ return this.compacting.has(convId);
+ }
+
isLocal(convId: string): boolean {
return this.local.has(convId);
}
@@ -65,6 +85,11 @@ export class ChatActivityStore {
this.remote.delete(convId);
}
+ /** A compaction summary started for the conv. */
+ markCompacting(convId: string, nodeId: string): void {
+ this.compacting.set(convId, nodeId);
+ }
+
/** A local pipe (send, attach or resume-wait) started for the conv. */
markLocal(convId: string): void {
this.local.add(convId);
diff --git a/tools/ui/src/lib/stores/chat/context-stats.svelte.ts b/tools/ui/src/lib/stores/chat/context-stats.svelte.ts
index b5d22cfbd..7324b071c 100644
--- a/tools/ui/src/lib/stores/chat/context-stats.svelte.ts
+++ b/tools/ui/src/lib/stores/chat/context-stats.svelte.ts
@@ -6,7 +6,7 @@
* and live processing state while streaming (chatStore).
*/
-import { MessageRole } from '$lib/enums';
+import { MessageRole, MessageType } from '$lib/enums';
// direct imports between stores, not via the barrel, to avoid circular deps
import { agenticStore } from '$lib/stores/agentic/index.svelte';
import { chatStore } from '$lib/stores/chat/index.svelte';
@@ -38,7 +38,7 @@ interface AssistantTimingsSummary {
/**
* One forward pass over the messages computing everything the deriveds
- * below need: the last assistant timings (per-turn gauges), the last
+ * below need: the last context bearing timings (per-turn gauges), the last
* agentic llm totals (cumulative gauge) and the cumulative sums. During
* streaming activeMessages churns every chunk, and each of these used to be
* its own O(n) scan re-run per chunk.
@@ -52,6 +52,15 @@ function summarizeAssistantTimings(messages: DatabaseMessage[]): AssistantTiming
let outputMs = 0;
for (const m of messages) {
+ // A stamped compaction node carries the prompt timings of the reduced
+ // context, so it supersedes earlier assistant turns as the current
+ // context size while staying out of the cumulative sums.
+ if (m.type === MessageType.COMPACTION && m.timings) {
+ lastTimings = m.timings;
+
+ continue;
+ }
+
if (m.role !== MessageRole.ASSISTANT || !m.timings) continue;
lastTimings = m.timings;
diff --git a/tools/ui/src/lib/stores/chat/flows.svelte.ts b/tools/ui/src/lib/stores/chat/flows.svelte.ts
index 16c377bb6..20cd4fb78 100644
--- a/tools/ui/src/lib/stores/chat/flows.svelte.ts
+++ b/tools/ui/src/lib/stores/chat/flows.svelte.ts
@@ -32,7 +32,8 @@ import {
findDescendantMessages,
findLeafNode,
findMessageById,
- isAbortError
+ isAbortError,
+ sliceAtLastCompaction
} from '$lib/utils';
/**
@@ -117,7 +118,9 @@ export class ChatMessageFlows {
// internal converter preserves tool_calls and extras when present.
// Reconstructing a bare {role, content} here would drop those fields
// and break continue_final_message for messages with tool calls.
- const contextWithContinue = conversationsStore.activeMessages.slice(0, idx + 1);
+ const contextWithContinue = sliceAtLastCompaction(
+ conversationsStore.activeMessages.slice(0, idx + 1)
+ );
let appendedContent = '';
let appendedReasoning = '';
diff --git a/tools/ui/src/lib/stores/chat/index.svelte.ts b/tools/ui/src/lib/stores/chat/index.svelte.ts
index 4bdcc6845..39454dc16 100644
--- a/tools/ui/src/lib/stores/chat/index.svelte.ts
+++ b/tools/ui/src/lib/stores/chat/index.svelte.ts
@@ -9,7 +9,12 @@
* Uses ChatService for the API layer and conversationsStore for persistence.
*/
-import { CWD_CLEARED_TEXT, SYSTEM_MESSAGE_PLACEHOLDER, TITLE_GENERATION } from '$lib/constants';
+import {
+ COMPACTION,
+ CWD_CLEARED_TEXT,
+ SYSTEM_MESSAGE_PLACEHOLDER,
+ TITLE_GENERATION
+} from '$lib/constants';
import {
ErrorDialogType,
MessageRole,
@@ -33,6 +38,7 @@ import { settingsStore } from '$lib/stores/settings/index.svelte';
import { toolsStore } from '$lib/stores/tools.svelte';
import type {
ApiChatMessageData,
+ ChatMessageCompactionTimings,
ChatMessagePromptProgress,
ChatMessageTimings,
ChatStreamCallbacks,
@@ -40,12 +46,17 @@ import type {
DatabaseMessageExtra,
ErrorDialogState
} from '$lib/types';
+import type { AgenticToolPolicy } from '$lib/types/agentic';
import {
+ canCompactMessages,
+ compactionCut,
+ filterByLeafNodeId,
findMessageById,
formatCwdMessage,
getConversationModel,
isAbortError,
- normalizeModelName
+ normalizeModelName,
+ sliceAtLastCompaction
} from '$lib/utils';
import { SvelteMap } from 'svelte/reactivity';
@@ -236,6 +247,14 @@ class ChatStore implements ChatStreamHost, ChatFlowsHost {
}
}
+ /**
+ * Whether the branch holds a step to summarize before the steps a
+ * compaction preserves.
+ */
+ canCompact(messages: DatabaseMessage[]): boolean {
+ return canCompactMessages(messages, this.compactionPreserve());
+ }
+
/**
* Resets the loading, streaming and processing state for a conversation
* after a generation ends or errors. Shared by the flows' exit paths.
@@ -269,6 +288,59 @@ class ChatStore implements ChatStreamHost, ChatFlowsHost {
this.pendingMessages.delete(convId);
}
+ /**
+ * Compacts the active branch: the model summarizes every turn since the
+ * last compaction boundary and the summary is stored in a new compaction
+ * node appended to the branch. Later requests slice the branch at that
+ * node, so the full history stays visible and navigable while the model
+ * context restarts from the summary.
+ */
+ async compactConversation(): Promise<void> {
+ const activeConv = conversationsStore.activeConversation;
+
+ if (!activeConv) return;
+
+ const convId = activeConv.id;
+
+ if (this.isChatLoadingInternal(convId) || agenticStore.isRunning(convId)) return;
+
+ // Snapshot of the reactive branch: the compaction node created below
+ // must not leak into the summarization request.
+ const source = sliceAtLastCompaction([...conversationsStore.activeMessages]);
+ const preserve = this.compactionPreserve();
+ const cut = compactionCut(source, preserve);
+
+ if (cut === null) return;
+
+ this.showErrorDialog(null);
+ this.setChatLoading(convId, true);
+
+ const node = await this.addMessage(
+ MessageRole.USER,
+ '',
+ MessageType.COMPACTION,
+ '-1',
+ undefined,
+ true
+ );
+
+ // An interrupted compaction leaves no summary behind, so the node goes
+ // along with the request instead of lingering empty in the branch.
+ await this.summarizeIntoNode(convId, node, source, async () => {
+ const idx = conversationsStore.findMessageIndex(node.id);
+
+ if (idx >= 0) conversationsStore.removeMessageAtIndex(idx);
+
+ await DatabaseService.deleteMessage(node.id);
+
+ if (node.parent) await conversationsStore.updateCurrentNode(node.parent);
+ });
+
+ const pending = this.consumePendingMessage(convId);
+
+ if (pending) await this.sendMessage(pending.content, pending.extras);
+ }
+
consumePendingDraft(): { message: string; files: ChatUploadedFile[] } | null {
if (!this.pendingDraftMessage && this.pendingDraftFiles.length === 0) return null;
@@ -455,6 +527,11 @@ class ChatStore implements ChatStreamHost, ChatFlowsHost {
return this.getChatStreamingState(convId);
}
+ /** Node id of the summary running for the conv, or null when none runs. */
+ getCompactingNodeId(convId: string): string | null {
+ return this.activity.getCompactingNodeId(convId);
+ }
+
async getDeletionInfo(messageId: string): Promise<{
totalCount: number;
userMessages: number;
@@ -507,6 +584,14 @@ class ChatStore implements ChatStreamHost, ChatFlowsHost {
return this.activity.isLocal(convId) || this.chatStreamingStates.has(convId);
}
+ /**
+ * True while the conv's in-flight run is a compaction summary, which reports
+ * its own progress on the compaction row.
+ */
+ isCompacting(convId: string): boolean {
+ return this.activity.isCompacting(convId);
+ }
+
isEditing(): boolean {
return this.isEditModeActive;
}
@@ -544,6 +629,46 @@ class ChatStore implements ChatStreamHost, ChatFlowsHost {
await this.addMessage(MessageRole.USER, content, MessageType.TEXT, '-1', undefined, true);
}
+ /**
+ * Re-runs the summary of an existing compaction node in place. The node
+ * keeps its position in the branch, so the turns below it stay untouched
+ * while the summarized context is rewritten.
+ */
+ async regenerateCompaction(messageId: string): Promise<void> {
+ const activeConv = conversationsStore.activeConversation;
+
+ if (!activeConv) return;
+
+ const convId = activeConv.id;
+
+ if (this.isChatLoadingInternal(convId) || agenticStore.isRunning(convId)) return;
+
+ const allMessages = await conversationsStore.getConversationMessages(convId);
+ const node = findMessageById(allMessages, messageId);
+
+ if (!node || node.type !== MessageType.COMPACTION) return;
+
+ // The summary covers the branch above the node: walking the path up to
+ // its parent keeps the old summary from being folded into the new one.
+ const branchPath = node.parent ? [...filterByLeafNodeId(allMessages, node.parent, false)] : [];
+ const source = sliceAtLastCompaction(branchPath);
+
+ if (!this.canCompact(source)) return;
+
+ this.showErrorDialog(null);
+ this.setChatLoading(convId, true);
+
+ const previous = { content: node.content, model: node.model };
+
+ await this.summarizeIntoNode(convId, node, source, async () => {
+ const idx = conversationsStore.findMessageIndex(node.id);
+
+ if (idx >= 0) conversationsStore.updateMessageAtIndex(idx, previous);
+
+ await DatabaseService.updateMessage(node.id, previous);
+ });
+ }
+
async regenerateMessage(messageId: string): Promise<void> {
return this.flows.regenerateMessage(messageId);
}
@@ -605,6 +730,7 @@ class ChatStore implements ChatStreamHost, ChatFlowsHost {
this.pendingDraftMessage = message;
this.pendingDraftFiles = [...files];
}
+
async sendMessage(content: string, extras?: DatabaseMessageExtra[]): Promise<void> {
if (!content.trim() && (!extras || extras.length === 0)) return;
@@ -732,7 +858,6 @@ class ChatStore implements ChatStreamHost, ChatFlowsHost {
});
}
}
-
setChatLoading(convId: string, loading: boolean): void {
if (loading) {
this.activity.markLocal(convId);
@@ -820,6 +945,11 @@ class ChatStore implements ChatStreamHost, ChatFlowsHost {
await modelsStore.props.fetchModelProps(effectiveModel);
}
+ // Every request path funnels through here, so the compaction boundary
+ // applied once covers the plain send, the agentic flow, and the
+ // pre-encode that reuses this list from the closure.
+ allMessages = sliceAtLastCompaction(allMessages);
+
// Mutable state for the current message being streamed
let currentMessageId = assistantMessage.id;
let streamedContent = '';
@@ -1057,13 +1187,7 @@ class ChatStore implements ChatStreamHost, ChatFlowsHost {
// Pre-encode conversation in KV cache for faster next turn
if (settingsStore.config.preEncodeConversation) {
- this.triggerPreEncode(
- allMessages,
- assistantMessage,
- streamedContent,
- effectiveModel,
- !!settingsStore.config.excludeReasoningFromContext
- );
+ void this.triggerPreEncode(convId, lastCreatedInFlow, effectiveModel);
}
},
onModel: (modelName: string) => recordModel(modelName),
@@ -1122,10 +1246,7 @@ class ChatStore implements ChatStreamHost, ChatFlowsHost {
await DatabaseService.updateMessage(messageId, updates);
}
};
- const toolPolicy = {
- disabledToolCategories: conversationsStore.preferences.getDisabledToolCategories(),
- disabledTools: conversationsStore.preferences.getDisabledTools()
- };
+ const toolPolicy = this.toolPolicy();
{
const agenticResult = await agenticStore.runAgenticFlow({
@@ -1197,6 +1318,11 @@ class ChatStore implements ChatStreamHost, ChatFlowsHost {
await conversationsStore.updateCurrentNode(currentMessageId);
cleanupStreamingState();
+ // Pre-encode conversation in KV cache for faster next turn
+ if (settingsStore.config.preEncodeConversation) {
+ void this.triggerPreEncode(convId, currentMessageId, effectiveModel);
+ }
+
if (onComplete) await onComplete(content);
if (serverStore.isRouterMode) modelsStore.fetchRouterModels().catch(console.error);
@@ -1257,6 +1383,7 @@ class ChatStore implements ChatStreamHost, ChatFlowsHost {
async updateMessage(messageId: string, newContent: string): Promise<void> {
return this.flows.updateMessage(messageId, newContent);
}
+
private abortRequest(convId?: string): void {
if (convId) {
const c = this.abortControllers.get(convId);
@@ -1271,6 +1398,11 @@ class ChatStore implements ChatStreamHost, ChatFlowsHost {
}
}
+ /** The steps a compaction keeps as written, as the settings set them. */
+ private compactionPreserve(): number {
+ return Number(settingsStore.config.compactionPreservedSteps);
+ }
+
private async generateTitleWithLLM(
userContent: string,
assistantContent: string,
@@ -1323,6 +1455,34 @@ class ChatStore implements ChatStreamHost, ChatFlowsHost {
return this.chatStreamingStates.get(convId);
}
+ /**
+ * Pre-encodes the branch ending at the leaf as the next turn reads it: the
+ * stored path cut at its last compaction, with the tools, message shaping
+ * and reasoning handling of the chat, so the server warms the very prefix
+ * that turn extends. Its prompt timings, or null when it returned none.
+ */
+ private async preEncodeBranch(
+ convId: string,
+ leafId: string,
+ model: string | null | undefined,
+ signal: AbortSignal
+ ): Promise<ChatMessageTimings | null> {
+ const allMessages = await conversationsStore.getConversationMessages(convId);
+ const branchPath = [...filterByLeafNodeId(allMessages, leafId, false)];
+ const request = await agenticStore.requestFor(
+ await ChatService.normalizeMessagesForApi(sliceAtLastCompaction(branchPath)),
+ this.toolPolicy()
+ );
+
+ return ChatService.preEncode(
+ request.messages,
+ model,
+ Boolean(settingsStore.config.excludeReasoningFromContext),
+ signal,
+ request.tools
+ );
+ }
+
private async savePartialResponseIfNeeded(convId?: string): Promise<void> {
const conversationId = convId || conversationsStore.activeConversation?.id;
@@ -1399,12 +1559,183 @@ class ChatStore implements ChatStreamHost, ChatFlowsHost {
}
}
- private async triggerPreEncode(
- allMessages: DatabaseMessage[],
- assistantMessage: DatabaseMessage,
- assistantContent: string,
+ /**
+ * Streams a fresh summary of `source` into the compaction node and re-stamps
+ * the reduced context afterwards. `rollback` restores the row when nothing
+ * usable came back, so an empty or aborted summary never replaces what the
+ * node already held.
+ */
+ private async summarizeIntoNode(
+ convId: string,
+ node: DatabaseMessage,
+ source: DatabaseMessage[],
+ rollback: () => Promise<void>
+ ): Promise<void> {
+ const abortController = this.getOrCreateAbortController(convId);
+ const compactionStreamId = COMPACTION.STREAM_ID_PREFIX + convId;
+ // Model resolution mirrors the request funnel: the selected model
+ // first, then the model the branch was generated with, so router
+ // mode targets the child that owns this conversation's KV cache.
+ const effectiveModel = serverStore.isRouterMode
+ ? modelsStore.selectedModelName || getConversationModel(source)
+ : undefined;
+
+ let summary = '';
+ // a summary the output limit cut is no summary
+ let truncated = false;
+ let summaryModel = node.model;
+ let summaryTimings: ChatMessageCompactionTimings | undefined;
+
+ const writeNode = (updates: Partial<DatabaseMessage>) => {
+ if (conversationsStore.activeConversation?.id !== convId) return;
+
+ const idx = conversationsStore.findMessageIndex(node.id);
+
+ if (idx >= 0) conversationsStore.updateMessageAtIndex(idx, updates);
+ };
+
+ // The node is the row that reports this run's progress, so the turns it
+ // summarizes stay quiet for as long as it runs.
+ this.activity.markCompacting(convId, node.id);
+ // Clearing the stored summary first hands the row over to the pending
+ // state, where it reports the prompt progress instead of the stale text.
+ writeNode({ content: '' });
+
+ // The steps the compaction keeps are part of the source the caller
+ // handed over; the summarization itself reads only what lies before
+ // them, so the summary describes the steps the model will still see.
+ const preserve = this.compactionPreserve();
+ const cut = compactionCut(source, preserve);
+
+ if (cut === null) {
+ await rollback();
+
+ return;
+ }
+
+ try {
+ // The request is the one the chat sends, its options, tools and
+ // message shaping, cut before the preserved steps: the server
+ // reuses the prefix it cached and only the instruction is new.
+ const request = await agenticStore.requestFor(
+ await ChatService.normalizeMessagesForApi(source.slice(0, cut)),
+ this.toolPolicy()
+ );
+ const configValue = settingsStore.config;
+ const compactionPrompt =
+ typeof configValue.compactionPrompt === 'string' && configValue.compactionPrompt.trim()
+ ? configValue.compactionPrompt
+ : COMPACTION.DEFAULT_PROMPT;
+
+ await ChatService.sendMessage(
+ [...request.messages, { content: compactionPrompt, role: MessageRole.USER }],
+ {
+ ...this.getApiOptions(),
+ model: effectiveModel,
+ onChunk: (chunk: string) => {
+ summary += chunk;
+ writeNode({ content: summary });
+ },
+ onComplete: (
+ _content: string,
+ _reasoning?: string,
+ _timings?: ChatMessageTimings,
+ _toolCalls?: string,
+ finishReason?: string
+ ) => {
+ truncated = finishReason === COMPACTION.FINISH_CUT;
+ },
+ onModel: (modelName: string) => {
+ summaryModel = modelName;
+ writeNode({ model: modelName });
+ },
+ // The summary streams through the same pipe as a chat turn, which
+ // enables prompt progress on the wire; feeding it to the shared
+ // processing state lets the pending row show the percentage. The
+ // closing event carries the timings the row reports once done.
+ onTimings: (timings?: ChatMessageTimings, promptProgress?: ChatMessagePromptProgress) => {
+ if (timings) summaryTimings = timings;
+
+ this.processing.applyStreamTimings(timings, promptProgress, convId);
+ },
+ stream: true,
+ timings_per_token: true,
+ tools: request.tools
+ },
+ // Registered under the prefixed identity: the visibility kick
+ // and byte-offset resume protect the summary through tab
+ // switches, while the reload probe for the bare conversation
+ // id never reattaches it as a chat turn.
+ compactionStreamId,
+ abortController.signal
+ );
+
+ if (truncated) throw new Error('The output limit cut the compaction summary');
+
+ if (!summary.trim()) {
+ await rollback();
+
+ return;
+ }
+
+ const stored: Partial<DatabaseMessage> = { content: summary, kept: preserve };
+
+ if (summaryModel) stored.model = summaryModel;
+
+ writeNode(stored);
+ await DatabaseService.updateMessage(node.id, stored);
+
+ // Re-encoding the reduced prefix is part of the compaction
+ // itself: it rebuilds the server KV cache on the summary and
+ // stamps the true context size on the node for the gauge,
+ // independently of the per-turn pre-fill setting.
+ void this.triggerCompactionPreEncode(convId, node.id, effectiveModel, summaryTimings);
+ } catch (error) {
+ await rollback();
+
+ if (isAbortError(error)) {
+ // The server keeps generating a detached summary otherwise.
+ void ChatService.cancelServerStream(compactionStreamId, effectiveModel);
+ } else {
+ console.error('Failed to compact conversation:', error);
+ this.showErrorDialog({
+ message: error instanceof Error ? error.message : 'Unknown error',
+ type: ErrorDialogType.SERVER
+ });
+ }
+ } finally {
+ this.abortControllers.delete(convId);
+ // Nothing infers for this conversation anymore: the summarization
+ // progress reported above must not outlive the request, or a
+ // skipped pre-encode would leave the gauge on a live state that
+ // describes the pre-compaction prompt.
+ this.processing.setState(convId, null);
+ this.setChatLoading(convId, false);
+ // Released last: ending it after the loading flag keeps the chat
+ // turns from flashing their own indicator for a tick.
+ this.activity.endCompacting(convId);
+ }
+ }
+
+ /** The tool policy of the active conversation, the one its requests run under. */
+ private toolPolicy(): AgenticToolPolicy {
+ return {
+ disabledToolCategories: conversationsStore.preferences.getDisabledToolCategories(),
+ disabledTools: conversationsStore.preferences.getDisabledTools()
+ };
+ }
+
+ /**
+ * Pre-encodes the compacted branch and stamps the returned prompt
+ * timings on the compaction node, so the context gauge reflects the
+ * reduced context as soon as the compaction finishes instead of after
+ * the next turn.
+ */
+ private async triggerCompactionPreEncode(
+ convId: string,
+ nodeId: string,
model?: string | null,
- excludeReasoning?: boolean
+ summaryTimings?: ChatMessageCompactionTimings
): Promise<void> {
this.cancelPreEncode();
this.preEncodeAbortController = new AbortController();
@@ -1414,14 +1745,68 @@ class ChatStore implements ChatStreamHost, ChatFlowsHost {
try {
const allIdle = await ChatService.areAllSlotsIdle(model, signal);
- if (!allIdle || signal.aborted) return;
+ if (!allIdle || signal.aborted) {
+ if (!signal.aborted) {
+ console.warn('[ChatStore] Compaction pre-encode skipped: slots busy');
+ }
+
+ return;
+ }
+
+ const timings = await this.preEncodeBranch(convId, nodeId, model, signal);
+
+ if (!timings || signal.aborted) {
+ if (!signal.aborted) {
+ console.warn('[ChatStore] Compaction pre-encode returned no timings');
+ }
- const messagesWithAssistant: DatabaseMessage[] = [
- ...allMessages,
- { ...assistantMessage, content: assistantContent }
- ];
+ return;
+ }
+
+ // The row reports both what the summary cost (summaryTimings) and the
+ // size of the context it left behind (timings), so the second is
+ // nested under the first instead of overwriting it.
+ const stamped = summaryTimings ? { ...timings, compaction: summaryTimings } : timings;
+
+ await DatabaseService.updateMessage(nodeId, { timings: stamped });
+
+ if (conversationsStore.activeConversation?.id === convId) {
+ const idx = conversationsStore.findMessageIndex(nodeId);
+
+ if (idx >= 0) conversationsStore.updateMessageAtIndex(idx, { timings: stamped });
+ }
+
+ // Nothing is inferring anymore: clearing the live processing
+ // state hands the gauge over to the stamped node, which any
+ // later restore also picks as the context truth.
+ this.processing.setState(convId, null);
+ } catch (err) {
+ if (!isAbortError(err)) {
+ console.warn('[ChatStore] Compaction pre-encode failed:', err);
+ }
+ }
+ }
+
+ /**
+ * Pre-encodes the branch ending at the leaf when every slot is idle, so
+ * the next turn finds its prefix already in the server KV cache.
+ */
+ private async triggerPreEncode(
+ convId: string,
+ leafId: string,
+ model?: string | null
+ ): Promise<void> {
+ this.cancelPreEncode();
+ this.preEncodeAbortController = new AbortController();
+
+ const signal = this.preEncodeAbortController.signal;
+
+ try {
+ const allIdle = await ChatService.areAllSlotsIdle(model, signal);
+
+ if (!allIdle || signal.aborted) return;
- await ChatService.preEncode(messagesWithAssistant, model, excludeReasoning, signal);
+ await this.preEncodeBranch(convId, leafId, model, signal);
} catch (err) {
if (!isAbortError(err)) {
console.warn('[ChatStore] Pre-encode failed:', err);
diff --git a/tools/ui/src/lib/stores/chat/processing.svelte.ts b/tools/ui/src/lib/stores/chat/processing.svelte.ts
index 69c1a6925..8f9897aae 100644
--- a/tools/ui/src/lib/stores/chat/processing.svelte.ts
+++ b/tools/ui/src/lib/stores/chat/processing.svelte.ts
@@ -8,7 +8,6 @@
* Composed under chatStore.processing; not exported from the stores barrel.
*/
-import { MessageRole } from '$lib/enums';
// direct imports between stores, not via the barrel, to avoid circular deps
import { modelsStore } from '$lib/stores/models/index.svelte';
import { serverStore } from '$lib/stores/server.svelte';
@@ -19,6 +18,7 @@ import type {
ChatMessageTimings,
DatabaseMessage
} from '$lib/types';
+import { lastContextBearingMessage } from '$lib/utils/compaction';
import { SvelteMap } from 'svelte/reactivity';
interface ProcessingTimingData {
@@ -79,27 +79,27 @@ export class ChatProcessingStore {
}
restoreFromMessages(messages: DatabaseMessage[], conversationId: string): void {
- for (let i = messages.length - 1; i >= 0; i--) {
- const message = messages[i];
-
- if (message.role === MessageRole.ASSISTANT && message.timings) {
- this.setState(
- conversationId,
- this.parseTimingData({
- cache_n: message.timings.cache_n || 0,
- predicted_n: message.timings.predicted_n || 0,
- predicted_per_second:
- message.timings.predicted_n && message.timings.predicted_ms
- ? (message.timings.predicted_n / message.timings.predicted_ms) * 1000
- : 0,
- prompt_ms: message.timings.prompt_ms,
- prompt_n: message.timings.prompt_n || 0
- })
- );
-
- return;
- }
- }
+ // The same source of truth as the context gauge: a stamped compaction
+ // node supersedes earlier assistant turns and restores to an idle
+ // state (predicted_n is 0), so the reduced context is never
+ // overridden by pre-compaction numbers.
+ const message = lastContextBearingMessage(messages);
+
+ if (!message?.timings) return;
+
+ this.setState(
+ conversationId,
+ this.parseTimingData({
+ cache_n: message.timings.cache_n || 0,
+ predicted_n: message.timings.predicted_n || 0,
+ predicted_per_second:
+ message.timings.predicted_n && message.timings.predicted_ms
+ ? (message.timings.predicted_n / message.timings.predicted_ms) * 1000
+ : 0,
+ prompt_ms: message.timings.prompt_ms,
+ prompt_n: message.timings.prompt_n || 0
+ })
+ );
}
setActiveConversation(conversationId: string | null): void {
diff --git a/tools/ui/src/lib/types/chat.d.ts b/tools/ui/src/lib/types/chat.d.ts
index 068662a63..191af46d3 100644
--- a/tools/ui/src/lib/types/chat.d.ts
+++ b/tools/ui/src/lib/types/chat.d.ts
@@ -85,6 +85,21 @@ export interface ChatMessageTimings {
prompt_ms?: number;
prompt_n?: number;
agentic?: ChatMessageAgenticTimings;
+ /**
+ * Compaction nodes only: the summarization request that wrote the summary,
+ * kept apart from the prompt timings the post-compaction pre-encode stamps
+ * for the context gauge (those describe the reduced context, not the work
+ * the summary cost).
+ */
+ compaction?: ChatMessageCompactionTimings;
+}
+
+export interface ChatMessageCompactionTimings {
+ cache_n?: number;
+ predicted_ms?: number;
+ predicted_n?: number;
+ prompt_ms?: number;
+ prompt_n?: number;
}
export interface ChatMessageAgenticTimings {
@@ -214,6 +229,8 @@ export interface FileMentionEntry {
* MCP server exposes prompts): visible but greyed out and not selectable.
*/
export interface ChatCommandsOptions {
+ /** Gates `/compact`. */
+ canCompact: () => boolean;
/** Gates `/model`. */
showModelSelector: boolean;
/** Gates `/prompt`. */
@@ -268,6 +285,8 @@ export interface ChatMessageActions {
newExtras?: DatabaseMessageExtra[]
) => void;
regenerateWithBranching: (message: DatabaseMessage, modelOverride?: string) => void;
+ /** Re-runs the summary of an existing compaction node and replaces it. */
+ regenerateCompaction: (message: DatabaseMessage) => void;
continueAssistantMessage: (message: DatabaseMessage) => void;
forkConversation: (
message: DatabaseMessage,
diff --git a/tools/ui/src/lib/types/database.d.ts b/tools/ui/src/lib/types/database.d.ts
index 57b77f06c..c2fca591a 100644
--- a/tools/ui/src/lib/types/database.d.ts
+++ b/tools/ui/src/lib/types/database.d.ts
@@ -134,6 +134,8 @@ export interface DatabaseMessage {
toolCwd?: string;
/** Internal flag marking a UI-generated message (e.g. a cwd change). The row is sent to the model as a "user" turn so chat templates accept it; the flag is only read by the renderer. */
isSynthetic?: boolean;
+ /** Steps a compaction node keeps as written before it, read by the model after its summary */
+ kept?: number;
children: string[];
extra?: DatabaseMessageExtra[];
timings?: ChatMessageTimings;
diff --git a/tools/ui/src/lib/types/index.ts b/tools/ui/src/lib/types/index.ts
index 820165cc9..4d9ae959a 100644
--- a/tools/ui/src/lib/types/index.ts
+++ b/tools/ui/src/lib/types/index.ts
@@ -68,6 +68,7 @@ export type {
ChatMessagePromptProgress,
ChatMessageTimings,
ChatMessageAgenticTimings,
+ ChatMessageCompactionTimings,
ChatMessageAgenticTurnStats,
ChatMessageToolCallTiming,
ChatStreamCallbacks,
diff --git a/tools/ui/src/lib/types/settings.d.ts b/tools/ui/src/lib/types/settings.d.ts
index 377a57040..ed05e407e 100644
--- a/tools/ui/src/lib/types/settings.d.ts
+++ b/tools/ui/src/lib/types/settings.d.ts
@@ -133,7 +133,8 @@ export interface SettingsChatServiceOptions {
response: string,
reasoningContent?: string,
timings?: ChatMessageTimings,
- toolCalls?: string
+ toolCalls?: string,
+ finishReason?: string
) => void;
onError?: (error: Error) => void;
onConnectionState?: (state: StreamConnectionState) => void;
diff --git a/tools/ui/src/lib/utils/chat-commands.ts b/tools/ui/src/lib/utils/chat-commands.ts
index 9345f0a48..89e36e130 100644
--- a/tools/ui/src/lib/utils/chat-commands.ts
+++ b/tools/ui/src/lib/utils/chat-commands.ts
@@ -30,6 +30,13 @@ export function getChatCommands(options: ChatCommandsOptions): ChatFormCommand[]
description: 'Select model',
disabled: !options.showModelSelector,
name: 'model'
+ },
+ {
+ action: ChatFormCommandAction.COMPACT,
+ description: 'Summarize the conversation to free up context',
+ disabled: !options.canCompact(),
+ keywords: ['summarize', 'context'],
+ name: 'compact'
}
];
}
diff --git a/tools/ui/src/lib/utils/compaction.ts b/tools/ui/src/lib/utils/compaction.ts
new file mode 100644
index 000000000..093dc618e
--- /dev/null
+++ b/tools/ui/src/lib/utils/compaction.ts
@@ -0,0 +1,144 @@
+import { COMPACTION } from '$lib/constants';
+import { MessageRole, MessageType } from '$lib/enums';
+import type { DatabaseMessage } from '$lib/types';
+
+/** Whether a message belongs to the prefix every request keeps first. */
+function isPrefix(message: DatabaseMessage): boolean {
+ return message.role === MessageRole.SYSTEM || message.type === MessageType.ROOT;
+}
+
+/** Whether a message is a compaction node holding its summary. */
+function isBoundary(message: DatabaseMessage): boolean {
+ return message.type === MessageType.COMPACTION && message.content.trim().length > 0;
+}
+
+/**
+ * Where each step of a message list starts. A step is what the model reads
+ * as one unit: a user message, or an assistant message with the tool
+ * results that follow it, so a cut never separates a tool call from its
+ * result. The prefix and the compaction nodes are no steps.
+ */
+function stepStarts(messages: DatabaseMessage[]): number[] {
+ const starts: number[] = [];
+
+ messages.forEach((m, i) => {
+ if (isPrefix(m) || m.type === MessageType.COMPACTION || m.role === MessageRole.TOOL) return;
+
+ starts.push(i);
+ });
+
+ return starts;
+}
+
+/**
+ * Applies the compaction boundary to a branch message list before it is
+ * sent to the model: the system prefix, then the last compaction node as a
+ * copy whose content frames its summary, then the steps it kept right
+ * before it, then everything after it. Older compaction nodes and the steps
+ * they summarized are dropped. The display row stays clean while the model
+ * receives the framing.
+ *
+ * A list without a compaction node is returned as-is.
+ */
+export function sliceAtLastCompaction(messages: DatabaseMessage[]): DatabaseMessage[] {
+ let boundary = -1;
+
+ for (let i = messages.length - 1; i >= 0; i--) {
+ // An empty compaction node is an interrupted compaction and carries
+ // no summary, so it never acts as a boundary.
+ if (isBoundary(messages[i])) {
+ boundary = i;
+
+ break;
+ }
+ }
+
+ if (boundary === -1) return messages;
+
+ const before = messages.slice(0, boundary);
+ const compactionNode = messages[boundary];
+ const framedNode: DatabaseMessage = {
+ ...compactionNode,
+ // a replacement function keeps the summary literal: a string would read
+ // $& or $$ in it as patterns
+ content: COMPACTION.CONTEXT_TEMPLATE.replace(
+ COMPACTION.SUMMARY_PLACEHOLDER,
+ () => compactionNode.content
+ )
+ };
+ const steps = before.filter((m) => !isPrefix(m) && m.type !== MessageType.COMPACTION);
+ const starts = stepStarts(steps);
+ const kept = Math.min(compactionNode.kept ?? 0, starts.length);
+ const from = kept > 0 ? starts[starts.length - kept] : steps.length;
+
+ return [
+ ...before.filter(isPrefix),
+ framedNode,
+ ...steps.slice(from),
+ ...messages.slice(boundary + 1)
+ ];
+}
+
+/**
+ * Where a compaction of the list the model reads cuts it: the summary
+ * request sends what comes before the cut, the very prefix the model last
+ * read, so the server reuses its cache, and the preserved steps after it
+ * stay as written. Null when no step lies before the preserved ones.
+ */
+export function compactionCut(view: DatabaseMessage[], preserve: number): number | null {
+ const starts = stepStarts(view);
+ const folded = starts.length - preserve;
+
+ if (folded < 1) return null;
+
+ return preserve > 0 ? starts[folded] : view.length;
+}
+
+/**
+ * A branch is compactable when the list the model reads holds at least one
+ * step before the preserved ones: anything less has nothing worth
+ * summarizing.
+ */
+export function canCompactMessages(messages: DatabaseMessage[], preserve: number): boolean {
+ return compactionCut(sliceAtLastCompaction(messages), preserve) !== null;
+}
+
+/**
+ * The context size the compaction shrank: the timings of the last stamped
+ * message ahead of the node, i.e. what the branch carried when the summary
+ * was requested. Null while nothing ahead of the node is stamped.
+ */
+export function compactionTokensBefore(
+ messages: DatabaseMessage[],
+ node: DatabaseMessage
+): number | null {
+ const nodeIndex = messages.findIndex((m) => m.id === node.id);
+ const timings =
+ nodeIndex > 0 ? lastContextBearingMessage(messages.slice(0, nodeIndex))?.timings : undefined;
+
+ if (!timings) return null;
+
+ const tokens = (timings.prompt_n ?? 0) + (timings.cache_n ?? 0) + (timings.predicted_n ?? 0);
+
+ return tokens > 0 ? tokens : null;
+}
+
+/**
+ * Returns the message carrying the current context size of the branch: the
+ * latest assistant turn or compaction node holding timings. A compaction
+ * node is stamped with the prompt timings of the reduced context by the
+ * post-compaction pre-encode, so it supersedes earlier assistant turns.
+ */
+export function lastContextBearingMessage(
+ messages: DatabaseMessage[]
+): DatabaseMessage | undefined {
+ for (let i = messages.length - 1; i >= 0; i--) {
+ const m = messages[i];
+
+ if (m.role === MessageRole.ASSISTANT && m.timings) return m;
+
+ if (m.type === MessageType.COMPACTION && m.timings) return m;
+ }
+
+ return undefined;
+}
diff --git a/tools/ui/src/lib/utils/index.ts b/tools/ui/src/lib/utils/index.ts
index c6f77d6a3..f6686a5a2 100644
--- a/tools/ui/src/lib/utils/index.ts
+++ b/tools/ui/src/lib/utils/index.ts
@@ -355,6 +355,13 @@ export { getToolUi } from './tool-ui';
// Chat command picker
export { getChatCommands } from './chat-commands';
+export {
+ canCompactMessages,
+ compactionCut,
+ compactionTokensBefore,
+ lastContextBearingMessage,
+ sliceAtLastCompaction
+} from './compaction';
// Sandbox tool definition
// SANDBOX_TOOL_DEFINITION is deprecated; kept for backward compatibility.
diff --git a/tools/ui/src/routes/(chat)/+layout.svelte b/tools/ui/src/routes/(chat)/+layout.svelte
index 84cdfeefe..191614d2e 100644
--- a/tools/ui/src/routes/(chat)/+layout.svelte
+++ b/tools/ui/src/routes/(chat)/+layout.svelte
@@ -25,7 +25,7 @@
<!-- the offset is the tab bar's full height (pt-3.25 + h-10), so the chat
column still fits the viewport and only scrolls when the messages overflow -->
-<div class={showTabs ? 'md:[--chat-tabs-offset:3.3125rem]' : ''}>
+<div class={showTabs ? 'md:[--chat-tabs-height:3.3125rem] md:[--chat-tabs-offset:3.3125rem]' : ''}>
{#if showTabs}
<ChatTabs />
{/if}
diff --git a/tools/ui/tests/client/collapsible-lazy-body.svelte.test.ts b/tools/ui/tests/client/collapsible-lazy-body.svelte.test.ts
index c82210176..3a9d019a4 100644
--- a/tools/ui/tests/client/collapsible-lazy-body.svelte.test.ts
+++ b/tools/ui/tests/client/collapsible-lazy-body.svelte.test.ts
@@ -4,7 +4,8 @@
// sets `hidden`, so before this was gated a collapsed tool result kept its whole
// body in the DOM and re-rendered it on every streamed token (measured at
// ~41ms/section/token for a 200KB result). These tests pin the fix: closed means
-// not rendered, and opening still mounts the body.
+// not rendered, and opening still mounts the body. The collapse is animated, so
+// "not rendered" happens when the transition ends rather than when it starts.
import CollapsibleLazyBodyHarness from './components/CollapsibleLazyBodyHarness.svelte';
import { tick } from 'svelte';
@@ -12,6 +13,8 @@ import { describe, expect, it } from 'vitest';
import { render } from 'vitest-browser-svelte';
const MARKER = 'collapsible-body-marker';
+// The shared region's collapse transition, plus a frame of slack.
+const COLLAPSE_TRANSITION_MS = 250;
describe('collapsible wrappers render their body lazily', () => {
for (const variant of ['content', 'terminal'] as const) {
@@ -27,9 +30,10 @@ describe('collapsible wrappers render their body lazily', () => {
expect(document.body.textContent).toContain(MARKER);
- // And it unmounts again on close, so a collapsed block stops costing
- // anything during streaming.
+ // And it unmounts again once the collapse is over, so a collapsed block
+ // stops costing anything during streaming.
await screen.rerender({ open: false, variant });
+ await new Promise((resolve) => setTimeout(resolve, COLLAPSE_TRANSITION_MS));
await tick();
expect(document.body.textContent).not.toContain(MARKER);
diff --git a/tools/ui/tests/client/components/ChatFormPickersHarness.svelte b/tools/ui/tests/client/components/ChatFormPickersHarness.svelte
index e8ef6465b..80f5080fe 100644
--- a/tools/ui/tests/client/components/ChatFormPickersHarness.svelte
+++ b/tools/ui/tests/client/components/ChatFormPickersHarness.svelte
@@ -9,6 +9,7 @@
const calls: string[] = [];
const pickers = useChatFormPickers({
+ canCompact: () => true,
focusInput: () => {},
getCaretOffset: () => caretOffset,
getCwd: () => null,
@@ -18,6 +19,9 @@
getValue: () => value,
hasCwdTools: () => true,
hasPrompts: () => true,
+ onCompact: () => {
+ calls.push('onCompact');
+ },
openModelSelector: () => {
calls.push('openModelSelector');
},
diff --git a/tools/ui/tests/client/tool-call-in-progress.svelte.test.ts b/tools/ui/tests/client/tool-call-in-progress.svelte.test.ts
index af32aef3c..e51fcb795 100644
--- a/tools/ui/tests/client/tool-call-in-progress.svelte.test.ts
+++ b/tools/ui/tests/client/tool-call-in-progress.svelte.test.ts
@@ -8,7 +8,7 @@
// default, in both directions.
import ChatMessageAgenticContent from '$lib/components/app/chat/ChatMessages/ChatMessageAgenticContent.svelte';
-import { SETTINGS_KEYS } from '$lib/constants';
+import { COLLAPSIBLE_TRANSITION_MS, SETTINGS_KEYS } from '$lib/constants';
import { MessageRole, MessageType } from '$lib/enums';
import { settingsStore } from '$lib/stores';
import type { DatabaseMessage } from '$lib/types';
@@ -21,9 +21,13 @@ const TOOL_NAME = 'custom_tool';
const TOOL_CALL_ID = 'call-1';
// Both markers sit inside the collapsible body, which CollapsibleContentBlock
// only mounts while open - so their presence is exactly "the block is expanded".
+// The collapse is animated and the body unmounts once it ends, so assertions
+// that a block closed wait for the transition first.
const PENDING_BODY = 'Waiting for result...';
const RESULT_BODY = 'marker-tool-output';
const TRIGGER_SELECTOR = '[data-slot="collapsible-trigger"]';
+// The collapse transition plus a frame of slack.
+const COLLAPSE_SETTLE_MS = COLLAPSIBLE_TRANSITION_MS + 50;
function assistantMessage(): DatabaseMessage {
return {
@@ -68,6 +72,11 @@ function textOf(container: Element): string {
return container.textContent ?? '';
}
+async function waitForCollapse(): Promise<void> {
+ await new Promise((resolve) => setTimeout(resolve, COLLAPSE_SETTLE_MS));
+ await tick();
+}
+
async function collapseBlock(screen: { container: Element }): Promise<void> {
const trigger = screen.container.querySelector(TRIGGER_SELECTOR);
@@ -130,7 +139,7 @@ describe('Show tool call in progress', () => {
message: assistantMessage(),
toolMessages: [toolResultMessage()]
});
- await tick();
+ await waitForCollapse();
expect(textOf(screen.container)).toContain(TOOL_NAME);
expect(textOf(screen.container)).not.toContain(PENDING_BODY);
@@ -156,7 +165,7 @@ describe('Show tool call in progress', () => {
message: assistantMessage(),
toolMessages: [toolResultMessage()]
});
- await tick();
+ await waitForCollapse();
expect(textOf(screen.container)).toContain(TOOL_NAME);
expect(textOf(screen.container)).not.toContain(RESULT_BODY);
@@ -190,6 +199,7 @@ describe('Show tool call in progress', () => {
expect(textOf(screen.container)).toContain(PENDING_BODY);
await collapseBlock(screen);
+ await waitForCollapse();
expect(textOf(screen.container)).not.toContain(PENDING_BODY);
});
diff --git a/tools/ui/tests/stories/ChatMessage.stories.svelte b/tools/ui/tests/stories/ChatMessage.stories.svelte
index a8f0243f9..bec9cbcd3 100644
--- a/tools/ui/tests/stories/ChatMessage.stories.svelte
+++ b/tools/ui/tests/stories/ChatMessage.stories.svelte
@@ -21,6 +21,7 @@
editWithReplacement: () => {},
forkConversation: () => {},
navigateToSibling: () => {},
+ regenerateCompaction: () => {},
regenerateWithBranching: () => {}
};
@@ -99,6 +100,77 @@
type: 'message'
});
+ const compactionMessage: DatabaseMessage = {
+ children: [],
+ content:
+ 'The user asked about the ultimate answer and its origin. Key facts: the answer is **42**, from Douglas Adams\' "The Hitchhiker\'s Guide to the Galaxy", computed by Deep Thought over 7.5 million years.\n\nNothing is left to do unless the user wants the question itself discussed.',
+ convId: 'conv-1',
+ id: '8',
+ // The model the server reports for the summary request.
+ model: 'ggml-org/Qwen3.8-Flash-Next',
+ parent: '1',
+ role: 'user',
+ thinking: '',
+ timestamp: Date.now() - 1000 * 60 * 30,
+ // The pre-encode stamps the reduced prompt on the node; `compaction`
+ // keeps the summary request's own timings for the stats row.
+ timings: {
+ cache_n: 0,
+ compaction: {
+ cache_n: 0,
+ predicted_ms: 12000,
+ predicted_n: 300,
+ prompt_ms: 18200,
+ prompt_n: 42200
+ },
+ predicted_n: 0,
+ prompt_n: 3204
+ },
+ type: 'compaction'
+ };
+
+ // The turn the compaction summarized: its timings are the context size
+ // the node's toggle reports as the "before" number.
+ const preCompactionAssistant: DatabaseMessage = {
+ children: [],
+ content: 'Deep Thought took 7.5 million years to answer.',
+ convId: 'conv-1',
+ id: '7',
+ parent: '1',
+ role: 'assistant',
+ thinking: '',
+ timestamp: Date.now() - 1000 * 60 * 31,
+ timings: { cache_n: 0, predicted_n: 118, prompt_n: 42200 },
+ type: 'message'
+ };
+
+ const compactionPendingMessage: DatabaseMessage = {
+ children: [],
+ content: '',
+ convId: 'conv-1',
+ id: '9',
+ parent: '1',
+ role: 'user',
+ thinking: '',
+ timestamp: Date.now() - 1000 * 60 * 29,
+ type: 'compaction'
+ };
+
+ // The same node once the prompt is through and the summary is streaming.
+ const compactionGeneratingMessage: DatabaseMessage = {
+ children: [],
+ content:
+ 'The user asked about the ultimate answer and its origin. Key facts: the answer is **42**',
+ convId: 'conv-1',
+ id: '10',
+ model: 'ggml-org/Qwen3.8-Flash-Next',
+ parent: '1',
+ role: 'user',
+ thinking: '',
+ timestamp: Date.now() - 1000 * 60 * 29,
+ type: 'compaction'
+ };
+
let streamingMessage = $state({
children: [],
content: '',
@@ -176,6 +248,95 @@
}}
/>
+<Story
+ args={{
+ class: 'max-w-[56rem] w-[calc(100vw-2rem)]',
+ message: compactionMessage
+ }}
+ name="Compaction"
+ play={async () => {
+ const { settingsStore } = await import('$lib/stores/settings/index.svelte');
+ const { conversationsStore } = await import('$lib/stores/conversations/index.svelte');
+
+ settingsStore.updateConfig('showMessageStats', true);
+ settingsStore.updateConfig('showRawOutputSwitch', false);
+ // The toggle reads the before size from the branch, so the story needs
+ // the summarized turn in the active message list.
+ conversationsStore.activeMessages = [preCompactionAssistant, compactionMessage];
+ }}
+/>
+
+<Story
+ args={{
+ class: 'max-w-[56rem] w-[calc(100vw-2rem)]',
+ message: compactionPendingMessage
+ }}
+ name="CompactionPrefill"
+ play={async () => {
+ const { settingsStore } = await import('$lib/stores/settings/index.svelte');
+ const { chatActivityStore } = await import('$lib/stores/chat/activity.svelte');
+ const { chatStore } = await import('$lib/stores/chat/index.svelte');
+
+ settingsStore.updateConfig('showRawOutputSwitch', false);
+
+ // The summary request reports its prompt progress through the shared
+ // processing state; seed one so the story shows the prefill detail.
+ chatActivityStore.markCompacting('conv-1', compactionPendingMessage.id);
+ chatStore.processing.setActiveConversation('conv-1');
+ chatStore.processing.setState('conv-1', {
+ contextTotal: 32768,
+ contextUsed: 10000,
+ hasNextToken: false,
+ outputTokensMax: -1,
+ outputTokensUsed: 0,
+ progressPercent: 42,
+ promptProgress: { cache: 0, processed: 4200, time_ms: 6000, total: 10000 },
+ promptTokens: 0,
+ speculative: false,
+ status: 'preparing',
+ temperature: 0.8,
+ tokensDecoded: 0,
+ tokensPerSecond: 0,
+ tokensRemaining: -1,
+ topP: 0.95
+ });
+ }}
+/>
+
+<Story
+ args={{
+ class: 'max-w-[56rem] w-[calc(100vw-2rem)]',
+ message: compactionGeneratingMessage
+ }}
+ name="CompactionGenerating"
+ play={async () => {
+ const { settingsStore } = await import('$lib/stores/settings/index.svelte');
+ const { chatActivityStore } = await import('$lib/stores/chat/activity.svelte');
+ const { chatStore } = await import('$lib/stores/chat/index.svelte');
+
+ settingsStore.updateConfig('showRawOutputSwitch', false);
+
+ // Prompt processing is over, so only the generated tokens are reported.
+ chatActivityStore.markCompacting('conv-1', compactionGeneratingMessage.id);
+ chatStore.processing.setActiveConversation('conv-1');
+ chatStore.processing.setState('conv-1', {
+ contextTotal: 32768,
+ contextUsed: 45200,
+ hasNextToken: true,
+ outputTokensMax: -1,
+ outputTokensUsed: 128,
+ promptTokens: 42200,
+ speculative: false,
+ status: 'generating',
+ temperature: 0.8,
+ tokensDecoded: 128,
+ tokensPerSecond: 25,
+ tokensRemaining: -1,
+ topP: 0.95
+ });
+ }}
+/>
+
<Story
args={{
message: streamingMessage
diff --git a/tools/ui/tests/unit/compaction.test.ts b/tools/ui/tests/unit/compaction.test.ts
new file mode 100644
index 000000000..d596f58bb
--- /dev/null
+++ b/tools/ui/tests/unit/compaction.test.ts
@@ -0,0 +1,287 @@
+import { COMPACTION } from '$lib/constants';
+import { MessageRole, MessageType } from '$lib/enums';
+import type { DatabaseMessage } from '$lib/types';
+import {
+ canCompactMessages,
+ compactionCut,
+ compactionTokensBefore,
+ sliceAtLastCompaction
+} from '$lib/utils';
+import { describe, expect, it } from 'vitest';
+
+const CONV_ID = 'conv';
+
+function makeMessage(
+ id: string,
+ role: MessageRole,
+ content: string,
+ type: MessageType = MessageType.TEXT
+): DatabaseMessage {
+ return {
+ children: [],
+ content,
+ convId: CONV_ID,
+ id,
+ parent: null,
+ role,
+ timestamp: 0,
+ type
+ } as DatabaseMessage;
+}
+
+function makeCompactionNode(id: string, summary: string, kept?: number): DatabaseMessage {
+ return { ...makeMessage(id, MessageRole.USER, summary, MessageType.COMPACTION), kept };
+}
+
+describe('sliceAtLastCompaction', () => {
+ it('returns the list unchanged when no compaction node is present', () => {
+ const messages = [
+ makeMessage('u1', MessageRole.USER, 'hi'),
+ makeMessage('a1', MessageRole.ASSISTANT, 'hello')
+ ];
+
+ expect(sliceAtLastCompaction(messages)).toBe(messages);
+ });
+
+ it('drops messages before the boundary and keeps the system prefix', () => {
+ const system = makeMessage('s1', MessageRole.SYSTEM, 'be brief', MessageType.SYSTEM);
+ const messages = [
+ system,
+ makeMessage('u1', MessageRole.USER, 'old question'),
+ makeMessage('a1', MessageRole.ASSISTANT, 'old answer'),
+ makeCompactionNode('c1', 'summary'),
+ makeMessage('u2', MessageRole.USER, 'new question')
+ ];
+ const sliced = sliceAtLastCompaction(messages);
+
+ expect(sliced.map((m) => m.id)).toEqual(['s1', 'c1', 'u2']);
+ expect(sliced[0]).toBe(system);
+ });
+
+ it('frames the compaction node content without mutating the original', () => {
+ const node = makeCompactionNode('c1', 'summary text');
+ const messages = [makeMessage('u1', MessageRole.USER, 'question'), node];
+ const sliced = sliceAtLastCompaction(messages);
+ const framed = COMPACTION.CONTEXT_TEMPLATE.replace(
+ COMPACTION.SUMMARY_PLACEHOLDER,
+ () => 'summary text'
+ );
+
+ expect(sliced[0].content).toBe(framed);
+ expect(sliced[0].role).toBe(MessageRole.USER);
+ expect(node.content).toBe('summary text');
+ });
+
+ it('frames a summary as written, whatever it holds', () => {
+ const summary = "echo $HOME $& $$ $'";
+ const sliced = sliceAtLastCompaction([makeCompactionNode('c1', summary)]);
+ const [before, after] = COMPACTION.CONTEXT_TEMPLATE.split(COMPACTION.SUMMARY_PLACEHOLDER);
+
+ expect(sliced[0].content).toBe(before + summary + after);
+ });
+
+ it('slices at the last boundary when the branch was compacted twice', () => {
+ const messages = [
+ makeCompactionNode('c1', 'first summary'),
+ makeMessage('u1', MessageRole.USER, 'question'),
+ makeMessage('a1', MessageRole.ASSISTANT, 'answer'),
+ makeCompactionNode('c2', 'second summary'),
+ makeMessage('u2', MessageRole.USER, 'follow-up')
+ ];
+ const sliced = sliceAtLastCompaction(messages);
+
+ expect(sliced.map((m) => m.id)).toEqual(['c2', 'u2']);
+ });
+
+ it('ignores an empty node left by an interrupted compaction', () => {
+ const messages = [
+ makeMessage('u1', MessageRole.USER, 'question'),
+ makeMessage('a1', MessageRole.ASSISTANT, 'answer'),
+ makeCompactionNode('c1', '')
+ ];
+
+ expect(sliceAtLastCompaction(messages)).toBe(messages);
+ });
+});
+
+describe('sliceAtLastCompaction with kept steps', () => {
+ const ids = (messages: DatabaseMessage[]) => messages.map((m) => m.id);
+
+ it('keeps the last steps before the node, a tool result with its call', () => {
+ const messages = [
+ makeMessage('s1', MessageRole.SYSTEM, 'be brief', MessageType.SYSTEM),
+ makeMessage('u1', MessageRole.USER, 'first'),
+ makeMessage('a1', MessageRole.ASSISTANT, 'calling'),
+ makeMessage('t1', MessageRole.TOOL, 'result'),
+ makeMessage('u2', MessageRole.USER, 'second'),
+ makeMessage('a2', MessageRole.ASSISTANT, 'answer'),
+ makeCompactionNode('c1', 'summary', 3),
+ makeMessage('u3', MessageRole.USER, 'next')
+ ];
+
+ expect(ids(sliceAtLastCompaction(messages))).toEqual([
+ 's1',
+ 'c1',
+ 'a1',
+ 't1',
+ 'u2',
+ 'a2',
+ 'u3'
+ ]);
+ });
+
+ it('counts the kept steps across an older node, which it drops', () => {
+ const messages = [
+ makeMessage('u1', MessageRole.USER, 'first'),
+ makeMessage('a1', MessageRole.ASSISTANT, 'one'),
+ makeCompactionNode('c1', 'first summary', 1),
+ makeMessage('u2', MessageRole.USER, 'second'),
+ makeMessage('a2', MessageRole.ASSISTANT, 'two'),
+ makeCompactionNode('c2', 'second summary', 3),
+ makeMessage('u3', MessageRole.USER, 'next')
+ ];
+
+ expect(ids(sliceAtLastCompaction(messages))).toEqual(['c2', 'a1', 'u2', 'a2', 'u3']);
+ });
+
+ it('keeps every step when it keeps more than there are, and none without kept', () => {
+ const messages = [
+ makeMessage('u1', MessageRole.USER, 'first'),
+ makeMessage('a1', MessageRole.ASSISTANT, 'one'),
+ makeCompactionNode('c1', 'summary', 9)
+ ];
+
+ expect(ids(sliceAtLastCompaction(messages))).toEqual(['c1', 'u1', 'a1']);
+ expect(
+ ids(sliceAtLastCompaction([...messages.slice(0, 2), makeCompactionNode('c1', 's')]))
+ ).toEqual(['c1']);
+ });
+});
+
+describe('compactionCut', () => {
+ const view = [
+ makeMessage('s1', MessageRole.SYSTEM, 'be brief', MessageType.SYSTEM),
+ makeMessage('u1', MessageRole.USER, 'first'),
+ makeMessage('a1', MessageRole.ASSISTANT, 'calling'),
+ makeMessage('t1', MessageRole.TOOL, 'result'),
+ makeMessage('u2', MessageRole.USER, 'second'),
+ makeMessage('a2', MessageRole.ASSISTANT, 'answer')
+ ];
+
+ it('cuts before the preserved steps, never between a call and its result', () => {
+ expect(compactionCut(view, 2)).toBe(4);
+ expect(compactionCut(view, 3)).toBe(2);
+ expect(compactionCut(view, 0)).toBe(view.length);
+ });
+
+ it('cuts nowhere when no step lies before the preserved ones', () => {
+ expect(compactionCut(view, 4)).toBeNull();
+ expect(compactionCut(view, 5)).toBeNull();
+ });
+
+ it('leaves the preserved steps as the model reads them once compacted', () => {
+ const cut = compactionCut(view, 2)!;
+ const compacted = sliceAtLastCompaction([...view, makeCompactionNode('c1', 'summary', 2)]);
+
+ expect(compacted.slice(2)).toEqual(view.slice(cut));
+ expect(compacted.slice(0, 2).map((m) => m.id)).toEqual(['s1', 'c1']);
+ });
+});
+
+describe('canCompactMessages', () => {
+ it('rejects a branch with no step before the preserved ones', () => {
+ expect(canCompactMessages([makeMessage('u1', MessageRole.USER, 'hi')], 1)).toBe(false);
+ });
+
+ it('accepts a branch with a step before the preserved ones', () => {
+ const messages = [
+ makeMessage('u1', MessageRole.USER, 'hi'),
+ makeMessage('a1', MessageRole.ASSISTANT, 'hello')
+ ];
+
+ expect(canCompactMessages(messages, 1)).toBe(true);
+ expect(canCompactMessages(messages, 2)).toBe(false);
+ });
+
+ it('rejects a branch with nothing new since the last compaction', () => {
+ const messages = [
+ makeMessage('u1', MessageRole.USER, 'hi'),
+ makeMessage('a1', MessageRole.ASSISTANT, 'hello'),
+ makeCompactionNode('c1', 'summary', 1)
+ ];
+
+ expect(canCompactMessages(messages, 1)).toBe(false);
+ });
+
+ it('accepts a compacted branch once a new step exists past the preserved ones', () => {
+ const messages = [
+ makeCompactionNode('c1', 'summary'),
+ makeMessage('u1', MessageRole.USER, 'next'),
+ makeMessage('a1', MessageRole.ASSISTANT, 'reply')
+ ];
+
+ expect(canCompactMessages(messages, 1)).toBe(true);
+ });
+
+ it('accepts a branch whose trailing compaction node is empty', () => {
+ const messages = [
+ makeMessage('u1', MessageRole.USER, 'hi'),
+ makeMessage('a1', MessageRole.ASSISTANT, 'hello'),
+ makeCompactionNode('c1', '')
+ ];
+
+ expect(canCompactMessages(messages, 1)).toBe(true);
+ });
+});
+
+describe('compactionTokensBefore', () => {
+ function withTimings(
+ message: DatabaseMessage,
+ timings: { cache_n?: number; predicted_n?: number; prompt_n?: number }
+ ): DatabaseMessage {
+ return { ...message, timings } as DatabaseMessage;
+ }
+
+ it('reports the context the branch carried before the compaction', () => {
+ const previous = withTimings(makeMessage('a1', MessageRole.ASSISTANT, 'hello'), {
+ cache_n: 200,
+ predicted_n: 118,
+ prompt_n: 42000
+ });
+ const node = makeCompactionNode('c1', 'summary');
+
+ expect(compactionTokensBefore([previous, node], node)).toBe(42318);
+ });
+
+ it('ignores the node own stamped timings', () => {
+ const previous = withTimings(makeMessage('a1', MessageRole.ASSISTANT, 'hello'), {
+ prompt_n: 42000
+ });
+ const node = withTimings(makeCompactionNode('c1', 'summary'), { prompt_n: 3204 });
+
+ expect(compactionTokensBefore([previous, node], node)).toBe(42000);
+ });
+
+ it('reports nothing while no message ahead of the node is stamped', () => {
+ const previous = makeMessage('a1', MessageRole.ASSISTANT, 'hello');
+ const node = withTimings(makeCompactionNode('c1', 'summary'), { prompt_n: 3204 });
+
+ expect(compactionTokensBefore([previous, node], node)).toBeNull();
+ });
+
+ it('reports nothing when the node is the first message', () => {
+ expect(
+ compactionTokensBefore(
+ [makeCompactionNode('c1', 'summary')],
+ makeCompactionNode('c1', 'summary')
+ )
+ ).toBeNull();
+ });
+
+ it('falls back to the previous compaction node when no turn follows it', () => {
+ const earlier = withTimings(makeCompactionNode('c0', 'older'), { prompt_n: 2800 });
+ const node = makeCompactionNode('c1', 'summary');
+
+ expect(compactionTokensBefore([earlier, node], node)).toBe(2800);
+ });
+});
diff --git a/tools/ui/tests/unit/stream-finish.test.ts b/tools/ui/tests/unit/stream-finish.test.ts
new file mode 100644
index 000000000..fded36966
--- /dev/null
+++ b/tools/ui/tests/unit/stream-finish.test.ts
@@ -0,0 +1,29 @@
+import { ChatService } from '$lib/services/chat.service';
+import { describe, expect, it } from 'vitest';
+
+// a streamed answer of one text delta, the chunk that ends it saying why
+function answer(finishReason: string): Response {
+ const chunks = [
+ { choices: [{ delta: { content: 'part' }, finish_reason: null }] },
+ { choices: [{ delta: {}, finish_reason: finishReason }] }
+ ];
+ const body = chunks.map((c) => `data: ${JSON.stringify(c)}\n\n`).join('') + 'data: [DONE]\n\n';
+
+ return new Response(body, { headers: { 'content-type': 'text/event-stream' } });
+}
+
+describe('ChatService stream end', () => {
+ it('tells why the model stopped, length once the output limit cut the answer', async () => {
+ const reasons: (string | undefined)[] = [];
+
+ for (const finish of ['length', 'stop']) {
+ await ChatService.handleStreamResponse(
+ answer(finish),
+ undefined,
+ (_content, _reasoning, _timings, _toolCalls, finishReason) => reasons.push(finishReason)
+ );
+ }
+
+ expect(reasons).toEqual(['length', 'stop']);
+ });
+});