Compare commits

...
Author SHA1 Message Date
Aleksander Grygier e285cfe2d8 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.
2026-10-21 10:38:23 +02:00
Aleksander Grygier 6f6689fe0b 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.
2026-10-21 10:27:29 +02:00
Aleksander Grygier 883f7387bd 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.
2026-10-21 10:27:29 +02:00
Aleksander Grygier 2894120ee1 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.
2026-10-21 10:27:29 +02:00
Aleksander Grygier 857354c064 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.
2026-10-21 10:27:29 +02:00
Aleksander Grygier 3b1e02fd69 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.
2026-10-21 10:27:29 +02:00
Aleksander Grygier accaf63d41 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.
2026-10-21 10:27:29 +02:00
Aleksander Grygier 584326fcec 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.
2026-10-21 10:27:29 +02:00
Aleksander Grygier afe06daa52 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.
2026-10-21 10:27:29 +02:00
Pascal 815edb76ad 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.
2026-10-21 09:45:43 +02:00
Pascal 6e24c83b2f ui: import the model option helpers from models/utils 2026-10-21 09:39:23 +02:00
Pascal e138b82720 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.
2026-10-21 09:39:23 +02:00
Pascal f9fc748df4 ui: align the compaction model field with project conventions 2026-10-21 09:39:23 +02:00
Pascal cddfa89091 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.
2026-10-21 09:39:23 +02:00
Pascal a8187b8b85 chore: format 2026-10-21 09:39:10 +02:00
Pascal ade61a6d7a 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.
2026-10-21 09:36:46 +02:00
Pascal 0543f7ba85 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.
2026-10-21 09:36:45 +02:00
50 changed files with 1681 additions and 571 deletions
+1 -2
View File
@@ -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 |
@@ -45,6 +45,7 @@
} from '$lib/types';
import {
buildMentionInsertion,
canCompactMessages,
containsCodeSpan,
containsFileMentionLink,
findCommandToken,
@@ -144,6 +145,7 @@
);
const pickers = useChatFormPickers({
canCompact: () => canCompactMessages(conversationsStore.activeMessages),
focusInput: refocusInput,
getCaretOffset: () => inputRef?.getCaretOffset(),
getCwd: () => cwd,
@@ -163,6 +165,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) => {
@@ -8,9 +8,12 @@
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 { canCompactMessages } from '$lib/utils';
import { formatParameters } from '$lib/utils/formatters';
const gauge = useContextGauge();
@@ -53,6 +56,25 @@
gauge.contextTotal > 0 &&
(gauge.activeModelId !== null || gauge.isActiveModelLoaded)
);
const showCompactButton = $derived(canCompactMessages(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 +84,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 +165,8 @@
{@render usage()}
{@render body()}
{@render compactAction()}
</div>
</Drawer.Content>
</Drawer.Root>
@@ -145,6 +189,8 @@
</div>
{@render body()}
{@render compactAction()}
</div>
</div>
{/if}
@@ -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(modelOverride?: string) {
chatActions.regenerateCompaction(message, modelOverride);
}
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}
@@ -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
);
@@ -188,7 +193,6 @@
onRawOutputToggle={(enabled) => (showRawOutput = enabled)}
{onRegenerate}
rawOutputEnabled={showRawOutput}
role={MessageRole.ASSISTANT}
showRawOutputSwitch={currentConfig.showRawOutputSwitch}
/>
{/if}
@@ -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
@@ -0,0 +1,171 @@
<script lang="ts">
import { FoldVertical, Loader2 } from '@lucide/svelte';
import {
ChatMessageActionIcons,
ChatMessageAssistantModel,
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, serverStore, settingsStore } from '$lib/stores';
import type { DatabaseMessage } from '$lib/types';
import { compactionTokensBefore, formatNumber } from '$lib/utils';
interface Props {
class?: string;
message: DatabaseMessage;
onRegenerate: (modelOverride?: string) => 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 isRouter = $derived(serverStore.isRouterMode);
// The model the server reported for the summary, so a compaction says who
// wrote it and the selector can re-summarize with another child.
const displayedModel = $derived(message.model ?? null);
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}
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 metadata lives with the summary, so the divider
stays a one-line headline. -->
{#if displayedModel}
<div class="info my-6 grid gap-4 tabular-nums">
<div class="inline-flex flex-wrap items-start gap-2 text-xs text-muted-foreground">
<ChatMessageAssistantModel
{displayedModel}
isLoading={chatStore.isLoading}
{isRouter}
{onRegenerate}
/>
<ChatMessageAssistantStatistics
isLoading={chatStore.isLoading}
{message}
{processingState}
showMessageStats={currentConfig.showMessageStats}
/>
</div>
</div>
{/if}
{#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>
@@ -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}
@@ -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}
@@ -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}
@@ -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}
@@ -13,11 +13,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[]>([]);
@@ -25,7 +24,6 @@
const chatActions: ChatMessageActions = {
continueAssistantMessage: async (message: DatabaseMessage) => {
onUserAction?.();
await chatStore.continueAssistantMessage(message.id);
refreshAllMessages();
},
@@ -51,7 +49,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);
@@ -62,7 +59,6 @@
newContent: string,
newExtras?: DatabaseMessageExtra[]
) => {
onUserAction?.();
await chatStore.editMessageWithBranching(message.id, newContent, newExtras);
refreshAllMessages();
},
@@ -72,7 +68,6 @@
newContent: string,
shouldBranch: boolean
) => {
onUserAction?.();
await chatStore.editAssistantMessage(message.id, newContent, shouldBranch);
// only a branch changes sibling info; an in-place edit already
@@ -91,8 +86,12 @@
await conversationsStore.navigateToSibling(siblingId);
},
regenerateCompaction: async (message: DatabaseMessage, modelOverride?: string) => {
await chatStore.regenerateCompaction(message.id, modelOverride);
refreshAllMessages();
},
regenerateWithBranching: async (message: DatabaseMessage, modelOverride?: string) => {
onUserAction?.();
await chatStore.regenerateMessageWithBranching(message.id, modelOverride);
refreshAllMessages();
}
@@ -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';
@@ -65,7 +66,8 @@
<div
bind:this={wrapperEl}
class:chat-message--synthetic={Boolean(message.isSynthetic)}
class:chat-message--synthetic={Boolean(message.isSynthetic) &&
message.type !== MessageType.COMPACTION}
class="chat-message"
>
{#if mounted}
@@ -11,13 +11,12 @@
ChatScreenStreamResumeStatus,
ServerLoadingSplash
} from '$lib/components/app';
import { LANDING_SETTLE_MAX_MS, LANDING_STABLE_FRAMES, ROUTES } from '$lib/constants';
import { createAutoScrollController } from '$lib/hooks/use-auto-scroll.svelte';
import { ROUTES } from '$lib/constants';
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,
@@ -26,16 +25,10 @@
settingsStore
} from '$lib/stores';
import { parseFilesToMessageExtras } from '$lib/utils/browser-only';
import { onDestroy, onMount, tick } from 'svelte';
import { onMount, tick } 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);
@@ -57,8 +50,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,
@@ -79,19 +73,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;
@@ -123,7 +104,8 @@
return false;
}
handleSendLikeScroll();
// Sending re-arms following: the reply streams in under the message.
stick.scrollToBottom();
await chatStore.sendMessage(message, result?.extras);
@@ -132,12 +114,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 or from a
// non-conversation route. The page keeps growing after the first pin
// without DOM mutations (content-visibility size realizations, syntax
// highlight passes), so the instant pin repeats every frame until the
// height settles, bailing out on user scroll or conversation change.
// 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;
@@ -147,80 +126,7 @@
lastScrolledConversationId = id;
await tick();
autoScroll.scrollToBottom();
const container = scroll.chatScrollContainer;
if (!container) return;
const started = performance.now();
let stableFrames = 0;
let lastHeight = container.scrollHeight;
const settle = () => {
if (autoScroll.userScrolledUp) return;
if (conversationsStore.activeConversation?.id !== id) return;
autoScroll.scrollToBottom();
const height = container.scrollHeight;
stableFrames = height === lastHeight ? stableFrames + 1 : 0;
lastHeight = height;
if (stableFrames >= LANDING_STABLE_FRAMES) return;
if (performance.now() - started > LANDING_SETTLE_MAX_MS) return;
requestAnimationFrame(settle);
};
requestAnimationFrame(settle);
}
function handleSendLikeScroll() {
if (!deviceStore.isMobile) {
autoScroll.enable();
}
setTimeout(() => {
const container = scroll.chatScrollContainer;
if (!container) return;
const lastUserBubble = container.querySelector(
'.chat-message:nth-last-child(2) .chat-message-user .chat-message-user-bubble'
) as HTMLElement | 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;
}
stick.scrollToBottom();
}
function handleErrorDialogOpenChange(open: boolean) {
@@ -238,15 +144,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(() => {
@@ -256,39 +160,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 />
@@ -306,9 +185,6 @@
<ChatMessages
messages={conversationsStore.activeMessages}
onMessagesReady={handleMessagesReady}
onUserAction={() => {
handleSendLikeScroll();
}}
/>
{/if}
@@ -338,16 +214,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>
@@ -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)
@@ -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,16 @@
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;
onToggle?: () => void;
children: Snippet;
}
@@ -23,6 +34,7 @@
let {
children,
class: className = '',
collapsible = true,
icon: IconComponent,
iconClass = ICON_CLASS_DEFAULT,
iconUrl = null,
@@ -31,7 +43,8 @@
shimmerTitle = false,
subtitle,
title = '',
titleSnippet
titleSnippet,
variant = 'default'
}: Props = $props();
function hideBrokenIcon(event: Event) {
@@ -39,6 +52,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 +114,55 @@
}}
{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="min-w-0 border-l border-muted-foreground/20 pl-4 pb-2 my-2">
{@render children()}
</div>
</div>
</CollapsibleRegion>
</Collapsible.Content>
{/if}
</Collapsible.Root>
@@ -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'}"
@@ -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);
});
@@ -1,6 +1,9 @@
<script lang="ts">
import { FlaskConical, RotateCcw } from '@lucide/svelte';
import { SettingsChatParameterSourceIndicator } from '$lib/components/app/settings';
import {
SettingsChatModelSelector,
SettingsChatParameterSourceIndicator
} from '$lib/components/app/settings';
import { Checkbox } from '$lib/components/ui/checkbox';
import { Input } from '$lib/components/ui/input';
import Label from '$lib/components/ui/label/label.svelte';
@@ -166,6 +169,27 @@
</Label>
</div>
{/if}
{:else if field.type === SettingsFieldType.MODEL_SELECT}
<Label class="flex items-center gap-1.5 text-sm font-medium" for={field.key}>
{field.label}
{#if field.isExperimental}
<FlaskConical class="h-3.5 w-3.5 text-muted-foreground" />
{/if}
</Label>
<SettingsChatModelSelector
id={field.key}
onSelect={(model) => onConfigChange(field.key, model)}
placeholder={`Select ${field.label.toLowerCase()}`}
value={String(localConfig[field.key] ?? '')}
/>
{#if field.help || SETTING_CONFIG_INFO[field.key]}
<p class="mt-1 text-xs text-muted-foreground">
{field.help || SETTING_CONFIG_INFO[field.key]}
</p>
{/if}
{:else if field.type === SettingsFieldType.SELECT}
{@const selectedOption = field.options?.find(
(opt: { value: string; label: string; icon?: Component }) =>
@@ -0,0 +1,70 @@
<script lang="ts">
import { ChevronDown } from '@lucide/svelte';
import { DropdownMenuSearchable, ModelsSelectorList } from '$lib/components/app';
import { Button } from '$lib/components/ui/button';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import { modelsStore } from '$lib/stores';
import { filterModelOptions, groupModelOptions } from '$lib/utils';
interface Props {
id: string;
onSelect: (model: string) => void;
placeholder: string;
value: string;
}
let { id, onSelect, placeholder, value }: Props = $props();
let open = $state(false);
let searchTerm = $state('');
let filteredOptions = $derived(filterModelOptions(modelsStore.models, searchTerm));
let groups = $derived(groupModelOptions(filteredOptions));
// The stored value survives a model leaving the router list, so the raw
// id is shown when no option matches it.
let triggerLabel = $derived(
modelsStore.models.find((m) => m.model === value)?.name || value || placeholder
);
function handleOpenChange(next: boolean) {
open = next;
searchTerm = '';
}
function handleSelect(modelId: string) {
const option = modelsStore.models.find((m) => m.id === modelId);
if (!option) return;
onSelect(option.model);
handleOpenChange(false);
}
</script>
<DropdownMenu.Root onOpenChange={handleOpenChange} {open}>
<DropdownMenu.Trigger {id}>
{#snippet child({ props })}
<Button {...props} class="w-full justify-between font-normal" variant="outline">
<span class="truncate">{triggerLabel}</span>
<ChevronDown class="h-4 w-4 shrink-0 opacity-50" />
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content
class="w-100 max-w-[calc(100vw-2rem)] pt-0"
onOpenAutoFocus={(event) => event.preventDefault()}
>
<DropdownMenuSearchable
bind:searchValue={searchTerm}
emptyMessage="No models found."
isEmpty={filteredOptions.length === 0}
placeholder="Search models..."
>
<div class="max-h-72 overflow-y-auto">
<ModelsSelectorList activeId={null} currentModel={value} {groups} onSelect={handleSelect} />
</div>
</DropdownMenuSearchable>
</DropdownMenu.Content>
</DropdownMenu.Root>
@@ -19,6 +19,13 @@ export { default as SettingsChatDesktopSidebar } from './SettingsChatDesktopSide
*/
export { default as SettingsChatMobileHeader } from './SettingsChatMobileHeader.svelte';
/**
* Model picker for settings fields. Shares the search, grouping and option
* rendering of the chat model selector, and writes the chosen model back
* through `onSelect`.
*/
export { default as SettingsChatModelSelector } from './SettingsChat/SettingsChatModelSelector.svelte';
/**
* Badge indicating parameter source for sampling settings. Shows one of:
* - **Custom**: User has explicitly set this value (orange badge)
@@ -1,13 +1,5 @@
export const AUTO_SCROLL_INTERVAL = 100;
// Conversation landing: the page keeps growing after the first bottom pin
// without DOM mutations (content-visibility size realizations, syntax
// highlight passes), so the pin repeats every frame until the height holds
// for this many consecutive frames, bounded by the time cap below.
export const LANDING_STABLE_FRAMES = 10;
export const LANDING_SETTLE_MAX_MS = 1000;
// 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;
@@ -0,0 +1,19 @@
/* 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}`,
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.',
// Setting value standing for "no dedicated model", so the conversation
// model writes the summary.
MODEL_UNSET: '',
// 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;
@@ -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
+1
View File
@@ -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';
@@ -11,6 +11,9 @@ export const SETTINGS_KEYS = {
API_KEY: 'apiKey',
AUTO_MIC_ON_EMPTY: 'autoMicOnEmpty',
BACKEND_SAMPLING: 'backend_sampling',
COMPACTION_MODEL: 'compactionModel',
COMPACTION_PROMPT: 'compactionPrompt',
COMPACTION_USE_CUSTOM_MODEL: 'compactionUseCustomModel',
CONVERSATION_TABS: 'conversationTabs',
COPY_TEXT_ATTACHMENTS_AS_PLAIN_TEXT: 'copyTextAttachmentsAsPlainText',
CUSTOM_CSS: 'customCss',
@@ -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,28 @@ export const SETTINGS_REGISTRY: SettingsSectionEntry[] = [
standaloneField: false,
type: SettingsFieldType.CHECKBOX
},
{
defaultValue: false,
help: 'Summarize with a model of your choice instead of the one the conversation runs on. Router mode only.',
key: SETTINGS_KEYS.COMPACTION_USE_CUSTOM_MODEL,
label: 'Compact with a different model',
type: SettingsFieldType.CHECKBOX
},
{
defaultValue: COMPACTION.MODEL_UNSET,
dependsOn: SETTINGS_KEYS.COMPACTION_USE_CUSTOM_MODEL,
help: 'Model that writes the summary. It is loaded on demand when it is not resident.',
key: SETTINGS_KEYS.COMPACTION_MODEL,
label: 'Compaction model',
type: SettingsFieldType.MODEL_SELECT
},
{
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: 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.',
@@ -257,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
@@ -10,6 +10,9 @@ export const COPY_FEEDBACK_RESET_MS = 1500;
/** How long a models dialog view fades out before the next one fades in. */
export const MODELS_DIALOG_VIEW_FADE_MS = 150;
/** 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';
+2
View File
@@ -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'
+1
View File
@@ -21,6 +21,7 @@ export enum SyncableParameterType {
export enum SettingsFieldType {
CHECKBOX = 'checkbox',
INPUT = 'input',
MODEL_SELECT = 'model_select',
RADIO = 'radio',
SELECT = 'select',
TEXTAREA = 'textarea'
@@ -1,226 +0,0 @@
import { AUTO_SCROLL_AT_BOTTOM_THRESHOLD, AUTO_SCROLL_INTERVAL } from '$lib/constants';
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
*/
export class AutoScrollController {
private _autoScrollEnabled = $state(true);
private _container: HTMLElement | undefined;
private _disabled: boolean;
private _lastScrollTop = $state(0);
private _mutationObserver: MutationObserver | null = null;
private _observerEnabled = false;
private _rafPending = false;
private _scrollInterval: ReturnType<typeof setInterval> | undefined;
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();
}
/**
* 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;
if (isScrollingUp && !isAtBottom) {
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._container = 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();
}
}
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);
}
@@ -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('');
@@ -1,48 +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 } from '$app/navigation';
export function useChatScreenScroll(autoScroll: AutoScrollController) {
let chatScrollContainer: HTMLElement | undefined = $state();
let isNavigating = $state(false);
function handleScroll(event: UIEvent) {
// Ignore scroll events caused by navigation layout changes or by our own
// programmatic scrolls so they don't accidentally disable auto-scroll.
if (isNavigating || !event.isTrusted) return;
autoScroll.handleScroll();
}
beforeNavigate(() => {
isNavigating = true;
autoScroll.resetScrollState();
});
afterNavigate(() => {
setTimeout(() => {
isNavigating = false;
autoScroll.resetScrollState();
}, 10);
});
$effect(() => {
chatScrollContainer = document.documentElement;
autoScroll.setContainer(chatScrollContainer);
});
return {
get chatScrollContainer() {
return chatScrollContainer;
},
handleScroll
};
}
@@ -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();
}
+23 -2
View File
@@ -842,7 +842,7 @@ export class ChatService {
model?: string | null,
excludeReasoning?: boolean,
signal?: AbortSignal
): Promise<void> {
): Promise<ChatMessageTimings | null> {
const normalizedMessages: ApiChatMessageData[] =
await ChatService.normalizeMessagesForApi(messages);
const requestBody: Record<string, unknown> = {
@@ -869,17 +869,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,
@@ -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);
@@ -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;
+5 -2
View File
@@ -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 = '';
+340 -4
View File
@@ -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,
@@ -41,11 +47,15 @@ import type {
ErrorDialogState
} from '$lib/types';
import {
canCompactMessages,
customCompactionModel,
filterByLeafNodeId,
findMessageById,
formatCwdMessage,
getConversationModel,
isAbortError,
normalizeModelName
normalizeModelName,
sliceAtLastCompaction
} from '$lib/utils';
import { SvelteMap } from 'svelte/reactivity';
@@ -269,6 +279,57 @@ 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]);
if (!canCompactMessages(source)) 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 +516,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 +573,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 +618,53 @@ 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; an explicit model re-summarizes
* with that model instead of the configured compaction one.
*/
async regenerateCompaction(messageId: string, modelOverride?: 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 (!canCompactMessages(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);
},
modelOverride
);
}
async regenerateMessage(messageId: string): Promise<void> {
return this.flows.regenerateMessage(messageId);
}
@@ -605,6 +726,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 +854,6 @@ class ChatStore implements ChatStreamHost, ChatFlowsHost {
});
}
}
setChatLoading(convId: string, loading: boolean): void {
if (loading) {
this.activity.markLocal(convId);
@@ -820,6 +941,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 = '';
@@ -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);
@@ -1270,7 +1397,6 @@ class ChatStore implements ChatStreamHost, ChatFlowsHost {
this.abortControllers.clear();
}
}
private async generateTitleWithLLM(
userContent: string,
assistantContent: string,
@@ -1399,6 +1525,216 @@ class ChatStore implements ChatStreamHost, ChatFlowsHost {
}
}
/**
* 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>,
modelOverride?: string
): Promise<void> {
const abortController = this.getOrCreateAbortController(convId);
const compactionStreamId = COMPACTION.STREAM_ID_PREFIX + convId;
const config = settingsStore.config;
// A dedicated compaction model wins over the funnel's own resolution:
// the model the user picked, then the configured summarizer, then the
// selected model, then the model the branch was generated with, so
// router mode targets the child that owns this conversation's KV cache.
const compactionModel = serverStore.isRouterMode
? customCompactionModel(Boolean(config.compactionUseCustomModel), config.compactionModel)
: undefined;
const effectiveModel = serverStore.isRouterMode
? modelOverride ||
compactionModel ||
modelsStore.selectedModelName ||
getConversationModel(source)
: undefined;
let summary = '';
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: '' });
try {
const apiMessages: ApiChatMessageData[] = await Promise.all(
source.map((m) => ChatService.convertDbMessageToApiChatMessageData(m))
);
const compactionPrompt =
typeof config.compactionPrompt === 'string' && config.compactionPrompt.trim()
? config.compactionPrompt
: COMPACTION.DEFAULT_PROMPT;
apiMessages.push({ content: compactionPrompt, role: MessageRole.USER });
// A dedicated model that is not resident is loaded on demand. The
// status manager surfaces the failure toast and throws, so the
// rollback below restores the node instead of leaving it empty.
if (compactionModel && !modelsStore.isModelLoaded(compactionModel)) {
await modelsStore.status.load(compactionModel);
}
await ChatService.sendMessage(
apiMessages,
{
custom: { chat_template_kwargs: { enable_thinking: false } },
model: effectiveModel,
onChunk: (chunk: string) => {
summary += chunk;
writeNode({ content: summary });
},
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
},
// 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 (!summary.trim()) {
await rollback();
return;
}
const stored: Partial<DatabaseMessage> = { content: summary };
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);
}
}
/**
* 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,
summaryTimings?: ChatMessageCompactionTimings
): 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) {
if (!signal.aborted) {
console.warn('[ChatStore] Compaction pre-encode skipped: slots busy');
}
return;
}
const allMessages = await conversationsStore.getConversationMessages(convId);
const branchPath = [...filterByLeafNodeId(allMessages, nodeId, false)];
const messages = sliceAtLastCompaction(branchPath);
const timings = await ChatService.preEncode(messages, model, undefined, signal);
if (!timings || signal.aborted) {
if (!signal.aborted) {
console.warn('[ChatStore] Compaction pre-encode returned no timings');
}
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);
}
}
}
private async triggerPreEncode(
allMessages: DatabaseMessage[],
assistantMessage: DatabaseMessage,
@@ -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];
// 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.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
})
);
if (!message?.timings) return;
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 {
+19
View File
@@ -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, modelOverride?: string) => void;
continueAssistantMessage: (message: DatabaseMessage) => void;
forkConversation: (
message: DatabaseMessage,
+1
View File
@@ -68,6 +68,7 @@ export type {
ChatMessagePromptProgress,
ChatMessageTimings,
ChatMessageAgenticTimings,
ChatMessageCompactionTimings,
ChatMessageAgenticTurnStats,
ChatMessageToolCallTiming,
ChatStreamCallbacks,
+7
View File
@@ -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'
}
];
}
+116
View File
@@ -0,0 +1,116 @@
import { COMPACTION } from '$lib/constants';
import { MessageRole, MessageType } from '$lib/enums';
import type { DatabaseMessage, SettingsConfigValue } from '$lib/types';
/**
* Applies the compaction boundary to a branch message list before it is
* sent to the model. Messages before the last compaction node are dropped,
* except system messages, which carry conversation-wide instructions. The
* compaction node itself is emitted as a copy whose content frames the
* stored summary, so 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 (messages[i].type === MessageType.COMPACTION && messages[i].content.trim().length > 0) {
boundary = i;
break;
}
}
if (boundary === -1) return messages;
const systemPrefix = messages
.slice(0, boundary)
.filter((m) => m.role === MessageRole.SYSTEM || m.type === MessageType.ROOT);
const compactionNode = messages[boundary];
const framedNode: DatabaseMessage = {
...compactionNode,
content: COMPACTION.CONTEXT_TEMPLATE.replace(
COMPACTION.SUMMARY_PLACEHOLDER,
compactionNode.content
)
};
return [...systemPrefix, framedNode, ...messages.slice(boundary + 1)];
}
/**
* The model dedicated to summarization, or undefined when the conversation
* model writes the summary. The opt-in flag gates the setting, and an unset
* or blank value keeps the conversation model.
*/
export function customCompactionModel(
useCustomModel: boolean,
model: SettingsConfigValue
): string | undefined {
if (!useCustomModel || typeof model !== 'string') return undefined;
const trimmed = model.trim();
return trimmed === COMPACTION.MODEL_UNSET ? undefined : trimmed;
}
/**
* A branch is compactable when at least one assistant turn with content
* exists after the last compaction boundary: anything less has nothing
* worth summarizing.
*/
export function canCompactMessages(messages: DatabaseMessage[]): boolean {
for (let i = messages.length - 1; i >= 0; i--) {
const m = messages[i];
if (m.type === MessageType.COMPACTION && m.content.trim().length > 0) return false;
if (m.role === MessageRole.ASSISTANT && m.content.trim().length > 0) return true;
}
return false;
}
/**
* 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;
}
+7
View File
@@ -352,6 +352,13 @@ export { getToolUi } from './tool-ui';
// Chat command picker
export { getChatCommands } from './chat-commands';
export {
canCompactMessages,
compactionTokensBefore,
customCompactionModel,
lastContextBearingMessage,
sliceAtLastCompaction
} from './compaction';
// Sandbox tool definition
// SANDBOX_TOOL_DEFINITION is deprecated; kept for backward compatibility.
+1 -1
View File
@@ -23,7 +23,7 @@
});
</script>
<div class={showTabs ? 'md:[--chat-tabs-offset:1.25rem]' : ''}>
<div class={showTabs ? 'md:[--chat-tabs-height:3.3125rem] md:[--chat-tabs-offset:1.25rem]' : ''}>
{#if showTabs}
<ChatTabs />
{/if}
@@ -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);
@@ -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');
},
@@ -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
+225
View File
@@ -0,0 +1,225 @@
import { COMPACTION } from '$lib/constants';
import { MessageRole, MessageType } from '$lib/enums';
import type { DatabaseMessage } from '$lib/types';
import {
canCompactMessages,
compactionTokensBefore,
customCompactionModel,
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): DatabaseMessage {
return makeMessage(id, MessageRole.USER, summary, MessageType.COMPACTION);
}
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('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('canCompactMessages', () => {
it('rejects a branch with no assistant turn', () => {
expect(canCompactMessages([makeMessage('u1', MessageRole.USER, 'hi')])).toBe(false);
});
it('accepts a branch with a non-empty assistant turn', () => {
const messages = [
makeMessage('u1', MessageRole.USER, 'hi'),
makeMessage('a1', MessageRole.ASSISTANT, 'hello')
];
expect(canCompactMessages(messages)).toBe(true);
});
it('rejects a branch whose only assistant turn is empty', () => {
const messages = [
makeMessage('u1', MessageRole.USER, 'hi'),
makeMessage('a1', MessageRole.ASSISTANT, ' ')
];
expect(canCompactMessages(messages)).toBe(false);
});
it('rejects a branch with nothing after the last compaction node', () => {
const messages = [
makeMessage('u1', MessageRole.USER, 'hi'),
makeMessage('a1', MessageRole.ASSISTANT, 'hello'),
makeCompactionNode('c1', 'summary')
];
expect(canCompactMessages(messages)).toBe(false);
});
it('accepts a compacted branch once a new assistant turn exists', () => {
const messages = [
makeCompactionNode('c1', 'summary'),
makeMessage('u1', MessageRole.USER, 'next'),
makeMessage('a1', MessageRole.ASSISTANT, 'reply')
];
expect(canCompactMessages(messages)).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)).toBe(true);
});
});
describe('customCompactionModel', () => {
it('keeps the conversation model when the opt-in is off', () => {
expect(customCompactionModel(false, 'summarizer')).toBeUndefined();
});
it('returns the selected model when the opt-in is on', () => {
expect(customCompactionModel(true, 'summarizer')).toBe('summarizer');
});
it('keeps the conversation model when no model is selected', () => {
expect(customCompactionModel(true, COMPACTION.MODEL_UNSET)).toBeUndefined();
});
it('keeps the conversation model for a blank value', () => {
expect(customCompactionModel(true, ' ')).toBeUndefined();
});
it('keeps the conversation model for a non string value', () => {
expect(customCompactionModel(true, undefined)).toBeUndefined();
});
});
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);
});
});