Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
ece705f
ui: add conversation compaction
ServeurpersoCom Aug 19, 2026
4de3a05
ui: pre-fill KV cache and refresh context gauge after compaction
ServeurpersoCom Aug 20, 2026
1c715b5
chore: format
ServeurpersoCom Aug 29, 2026
d5abf0e
ui: allow a dedicated model for conversation compaction
ServeurpersoCom Aug 29, 2026
503526f
ui: align the compaction model field with project conventions
ServeurpersoCom Aug 29, 2026
10452ee
ui: fix the model option helpers import path
ServeurpersoCom Sep 10, 2026
61f78f6
ui: import the model option helpers from models/utils
ServeurpersoCom Sep 17, 2026
77b71b2
ui: follow the moved models selector in the compaction model field
ServeurpersoCom Oct 21, 2026
b07cea7
ui: drop the dedicated compaction model
ServeurpersoCom Oct 21, 2026
e5de1da
ui: keep the last steps of a compacted conversation
ServeurpersoCom Oct 21, 2026
834c972
ui: run every compaction summary through one path
allozaur Oct 21, 2026
b2b7585
ui: give the compaction row its own summary UI
allozaur Oct 21, 2026
2789df5
ui: report compaction progress on the row
allozaur Oct 21, 2026
2be32c2
ui: keep the summarized turns quiet while a compaction runs
allozaur Oct 21, 2026
7507f06
ui: hand the compact action to the context gauge
allozaur Oct 21, 2026
d132a2d
ui: follow the bottom, otherwise leave the view alone
allozaur Oct 21, 2026
8d3d320
ui: expand collapsible bodies with a height transition
allozaur Oct 21, 2026
feb5d1c
ui: open a compaction summary at the top of the screen
allozaur Oct 21, 2026
5c5bb92
ui: spin the compaction icon while the summary runs
allozaur Oct 21, 2026
916dfa7
ui: let the compaction summary speak without the reasoning trimmings
allozaur Oct 21, 2026
416e84c
ui: drop the model override from the compaction path
ServeurpersoCom Oct 21, 2026
19e254c
ui: let the compaction summary think as the chat does
ServeurpersoCom Oct 21, 2026
9d661d7
ui: pre-encode the compacted branch as the next turn reads it
ServeurpersoCom Oct 21, 2026
e8f6e2f
ui: pre-encode the branch the next turn actually reads
ServeurpersoCom Oct 21, 2026
c4e3472
ui: refuse a compaction summary the output limit cut
ServeurpersoCom Oct 21, 2026
4578c0a
ui: wait for the collapse transition in the tool call tests
allozaur Oct 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions tools/ui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@
);

const pickers = useChatFormPickers({
canCompact: () => chatStore.canCompact(conversationsStore.activeMessages),
focusInput: refocusInput,
getCaretOffset: () => inputRef?.getCaretOffset(),
getCwd: () => cwd,
Expand All @@ -163,6 +164,7 @@
.getServers()
.some((s) => s.enabled && prefs.isServerToolsEnabled(s.id) && s.url.trim());
},
onCompact: () => chatStore.compactConversation(),
openModelSelector: () => chatFormActionsRef?.openModelSelector(),
setCaretOffset: (offset) => inputRef?.setCaretOffset(offset),
setValue: (v) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,11 @@
gaugePopup,
gaugePopupClose
} from './gauge-popup.svelte';
import { FoldVertical, Loader2 } from '@lucide/svelte';
import { Button } from '$lib/components/ui/button';
import * as Drawer from '$lib/components/ui/drawer';
import { useContextGauge } from '$lib/hooks/use-context-gauge.svelte';
import { deviceStore } from '$lib/stores';
import { agenticStore, chatStore, conversationsStore, deviceStore } from '$lib/stores';
import { formatParameters } from '$lib/utils/formatters';

const gauge = useContextGauge();
Expand Down Expand Up @@ -53,6 +55,25 @@
gauge.contextTotal > 0 &&
(gauge.activeModelId !== null || gauge.isActiveModelLoaded)
);

const showCompactButton = $derived(chatStore.canCompact(conversationsStore.activeMessages));

const activeConvId = $derived(conversationsStore.activeConversation?.id ?? null);

// A summary in flight keeps the card open for the numbers, so the action
// reports the run instead of inviting a second one.
const isCompacting = $derived(activeConvId !== null && chatStore.isCompacting(activeConvId));

const isCompactDisabled = $derived.by(() => {
if (!activeConvId) return true;

return chatStore.isChatLoading(activeConvId) || agenticStore.isRunning(activeConvId);
});

function handleCompact() {
gaugePopupClose();
chatStore.compactConversation();
}
</script>

{#snippet usage()}
Expand All @@ -62,6 +83,26 @@
</span>
{/snippet}

{#snippet compactAction()}
{#if showCompactButton}
<Button
class="my-1 mt-3 w-full gap-1.5"
disabled={isCompactDisabled}
onclick={handleCompact}
size="sm"
variant="tertiary"
>
{#if isCompacting}
<Loader2 class="h-3.5 w-3.5 animate-spin" />
Compacting...
{:else}
<FoldVertical class="h-3.5 w-3.5" />
Compact conversation
{/if}
</Button>
{/if}
{/snippet}

{#snippet body()}
{#if gauge.activeModelId !== null && !gauge.isActiveModelLoaded}
<ContextGaugeLoadModel
Expand Down Expand Up @@ -123,6 +164,8 @@
{@render usage()}

{@render body()}

{@render compactAction()}
</div>
</Drawer.Content>
</Drawer.Root>
Expand All @@ -145,6 +188,8 @@
</div>

{@render body()}

{@render compactAction()}
</div>
</div>
{/if}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import { goto } from '$app/navigation';
import {
ChatMessageAssistant,
ChatMessageCompaction,
ChatMessageMcpPrompt,
ChatMessageSynthetic,
ChatMessageSystem,
Expand All @@ -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 {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -313,6 +315,10 @@
chatActions.regenerateWithBranching(message, modelOverride);
}

function handleRegenerateCompaction() {
chatActions.regenerateCompaction(message);
}

function handleContinue() {
chatActions.continueAssistantMessage(message);
}
Expand Down Expand Up @@ -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}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -66,6 +69,7 @@
let showProcessingInfoTop = $derived(
message?.role === MessageRole.ASSISTANT &&
isActivelyProcessing &&
!isCompacting &&
hasNoContent &&
!isAgentic &&
isLastAssistantMessage
Expand All @@ -74,6 +78,7 @@
let showProcessingInfoBottom = $derived(
message?.role === MessageRole.ASSISTANT &&
isActivelyProcessing &&
!isCompacting &&
(!hasNoContent || isAgentic) &&
isLastAssistantMessage
);
Expand Down Expand Up @@ -212,7 +217,6 @@
onRawOutputToggle={(enabled) => (showRawOutput = enabled)}
{onRegenerate}
rawOutputEnabled={showRawOutput}
role={MessageRole.ASSISTANT}
showRawOutputSwitch={currentConfig.showRawOutputSwitch}
/>
{/if}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
<script lang="ts">
import { FoldVertical, Loader2 } from '@lucide/svelte';
import {
ChatMessageActionIcons,
ChatMessageAssistantStatistics,
ChatMessageEditForm,
CollapsibleContentBlock,
MarkdownContent
} from '$lib/components/app';
import { COLLAPSIBLE_TRANSITION_MS } from '$lib/constants';
import { getChatMessageEditContext } from '$lib/contexts';
import { useProcessingState } from '$lib/hooks/use-processing-state.svelte';
import { chatStore, conversationsStore, settingsStore } from '$lib/stores';
import type { DatabaseMessage } from '$lib/types';
import { compactionTokensBefore, formatNumber } from '$lib/utils';

interface Props {
class?: string;
message: DatabaseMessage;
onRegenerate: () => void;
}

let { class: className = '', message, onRegenerate }: Props = $props();

const COMPACTION_HEADER = 'Conversation compacted';
const COMPACTION_RUNNING_LABEL = 'Compacting conversation';
const COMPACTION_GENERATING_LABEL = 'Generating summary';

// The shared row context, so the summary is editable and branchable through
// the same actions as a chat turn.
const editCtx = getChatMessageEditContext();

const currentConfig = $derived(settingsStore.config);
const hasContent = $derived(message.content.trim().length > 0);

// Only the node a running summary writes to reports progress: a finished
// compaction further up the branch keeps its headline while another one runs.
const isSummarizing = $derived(chatStore.getCompactingNodeId(message.convId) === message.id);

const processingState = useProcessingState();

// The summary request feeds the shared processing state, so the body shows
// the same prompt progress line a chat turn does while it waits.
$effect(() => {
if (isSummarizing) processingState.startMonitoring();
});

// The header carries the run: the prompt progress until the summary starts
// streaming, the phase after that. There is nothing to collapse before the
// first summary token arrives.
const runningLabel = $derived.by(() => {
// The prompt is through: whatever streams in now is the summary itself.
if (hasContent) return `${COMPACTION_RUNNING_LABEL} — ${COMPACTION_GENERATING_LABEL}`;

const progress = processingState.getPromptProgressText();

return progress
? `${COMPACTION_RUNNING_LABEL} — ${progress}`
: `${COMPACTION_RUNNING_LABEL}...`;
});

// Shrinking the branch is the feature's own headline, so the finished
// toggle reports the context size it compacted.
const tokensBefore = $derived(compactionTokensBefore(conversationsStore.activeMessages, message));

const completedLabel = $derived(
tokensBefore === null
? COMPACTION_HEADER
: `${COMPACTION_HEADER} from ${formatNumber(tokensBefore)}`
);

const title = $derived(isSummarizing || !hasContent ? runningLabel : completedLabel);

let open = $state(false);
// A toggle by hand outranks the in-progress expansion, the way the agentic
// sections treat their own overrides.
let userChoseOpen = $state<boolean | null>(null);

// The progress lives in the body (as it does for an assistant turn), so the
// row opens while the summary runs and collapses once it is done.
$effect(() => {
if (userChoseOpen !== null) return;

open = isSummarizing;
});

let rootEl = $state<HTMLDivElement>();
// The scroll waiting for the body to reach its height, so closing the block
// before that leaves no scroll behind it.
let settleTimer: ReturnType<typeof setTimeout> | null = null;

/**
* A toggle hands the reader the block's own boundary, just below the tabs: one
* scroll, and only once the body has its height - before that the page is too
* short to scroll the divider up there and the call would clamp.
*/
function handleToggle() {
userChoseOpen = open;
cancelSettledScroll();

if (!rootEl) return;

settleTimer = setTimeout(() => {
settleTimer = null;
rootEl?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}, COLLAPSIBLE_TRANSITION_MS);
}

function cancelSettledScroll() {
if (settleTimer === null) return;

clearTimeout(settleTimer);
settleTimer = null;
}

$effect(() => cancelSettledScroll);
</script>

<div bind:this={rootEl} class="scroll-mt-[calc(var(--chat-tabs-height,0px)+1rem)] {className}">
{#if editCtx.isEditing}
<ChatMessageEditForm />
{:else}
<CollapsibleContentBlock
bind:open
class="my-2"
collapsible={hasContent}
flushBody
icon={isSummarizing ? Loader2 : FoldVertical}
iconClass={isSummarizing ? 'h-3.5 w-3.5 animate-spin' : 'h-3.5 w-3.5'}
onToggle={handleToggle}
shimmerTitle={isSummarizing || !hasContent}
{title}
variant="divider"
>
<MarkdownContent class="text-muted-foreground" content={message.content} />

<!-- The summary's own statistics live with the summary, so the divider
stays a one-line headline. Only the conversation model writes the
summary, so there is no selector to swap it with. -->
<div class="info my-6 grid gap-4 tabular-nums">
<ChatMessageAssistantStatistics
isLoading={chatStore.isLoading}
{message}
{processingState}
showMessageStats={currentConfig.showMessageStats}
/>
</div>

{#if hasContent && !isSummarizing}
<!-- Copy, edit, fork, regenerate and delete all apply to the summary:
regenerate re-runs it in place. Continue stays a chat-turn flow. -->
<ChatMessageActionIcons actionsPosition="left" justify="start" {onRegenerate} />
{/if}
</CollapsibleContentBlock>
{/if}
</div>
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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}
Expand Down
Loading
Loading