feat(ui): show in-chat system message when context compaction occurs

Add CompactionItem component that renders a subtle inline notification
in the chat stream when the agent compacts its context window. Extends
Message type with "system" role and handles compaction_end events in
useChat's handleStream.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
yushen 2026-02-12 16:10:19 +08:00
parent 9c52dc0124
commit 8f4e894370
4 changed files with 95 additions and 2 deletions

View file

@ -9,13 +9,22 @@ import {
type AgentMessageItem,
type ExecApprovalRequestPayload,
type ApprovalDecision,
type CompactionEndEvent,
} from "@multica/sdk";
export type ToolStatus = "running" | "success" | "error" | "interrupted";
export interface CompactionInfo {
removed: number;
kept: number;
tokensRemoved?: number;
tokensKept?: number;
reason: string;
}
export interface Message {
id: string;
role: "user" | "assistant" | "toolResult";
role: "user" | "assistant" | "toolResult" | "system";
content: ContentBlock[];
agentId: string;
stopReason?: string;
@ -24,6 +33,8 @@ export interface Message {
toolArgs?: Record<string, unknown>;
toolStatus?: ToolStatus;
isError?: boolean;
systemType?: "compaction";
compaction?: CompactionInfo;
}
export interface ChatError {
@ -215,6 +226,27 @@ export function useChat() {
}
case "tool_execution_update":
break;
case "compaction_end": {
const ce = event as CompactionEndEvent;
setMessages((prev) => [
...prev,
{
id: uuidv7(),
role: "system",
content: [],
agentId: payload.agentId,
systemType: "compaction",
compaction: {
removed: ce.removed,
kept: ce.kept,
tokensRemoved: ce.tokensRemoved,
tokensKept: ce.tokensKept,
reason: ce.reason,
},
},
]);
break;
}
}
}, []);

View file

@ -2,9 +2,17 @@ import type { ContentBlock } from "@multica/sdk"
export type ToolStatus = "running" | "success" | "error" | "interrupted"
export interface CompactionInfo {
removed: number
kept: number
tokensRemoved?: number
tokensKept?: number
reason: string
}
export interface Message {
id: string
role: "user" | "assistant" | "toolResult"
role: "user" | "assistant" | "toolResult" | "system"
content: ContentBlock[]
agentId: string
stopReason?: string
@ -13,4 +21,6 @@ export interface Message {
toolArgs?: Record<string, unknown>
toolStatus?: ToolStatus
isError?: boolean
systemType?: "compaction"
compaction?: CompactionInfo
}

View file

@ -0,0 +1,45 @@
"use client"
import { memo } from "react"
import { Scissors } from "lucide-react"
import type { Message } from "@multica/store"
function formatTokens(n: number): string {
if (n >= 1000) return `~${(n / 1000).toFixed(1)}k`
return `${n}`
}
interface CompactionItemProps {
message: Message
}
export const CompactionItem = memo(function CompactionItem({ message }: CompactionItemProps) {
const info = message.compaction
if (!info) return null
const label = info.reason === "summary" ? "Context summarized" : "Context compacted"
const removed = `${info.removed} messages removed`
const tokens = info.tokensRemoved != null
? `, ${formatTokens(info.tokensRemoved)} tokens freed`
: ""
return (
<div className="py-0.5 px-2.5 text-sm text-muted-foreground">
<div className="flex items-center gap-1.5 px-2.5 py-1">
{/* Status dot */}
<span className="size-1.5 rounded-full shrink-0 bg-muted-foreground/40" />
{/* Icon */}
<Scissors className="size-3.5 shrink-0" />
{/* Label */}
<span className="font-medium shrink-0">{label}</span>
{/* Stats */}
<span className="ml-auto text-xs text-muted-foreground/60 shrink-0">
{removed}{tokens}
</span>
</div>
</div>
)
})

View file

@ -5,6 +5,7 @@ import { MemoizedMarkdown } from "@multica/ui/components/markdown";
import { StreamingMarkdown } from "@multica/ui/components/markdown/StreamingMarkdown";
import { ToolCallItem } from "@multica/ui/components/tool-call-item";
import { ThinkingItem } from "@multica/ui/components/thinking-item";
import { CompactionItem } from "@multica/ui/components/compaction-item";
import { cn, getTextContent } from "@multica/ui/lib/utils";
import type { Message } from "@multica/store";
import type { ContentBlock, ToolCall, ThinkingContent } from "@multica/sdk";
@ -78,6 +79,11 @@ export const MessageList = memo(function MessageList({ messages, streamingIds }:
return (
<div className="relative p-6 px-4 sm:px-10 max-w-4xl mx-auto">
{messages.map((msg) => {
// System messages (e.g. compaction notifications)
if (msg.role === "system") {
return <CompactionItem key={msg.id} message={msg} />
}
// ToolResult messages → render as tool execution item
if (msg.role === "toolResult") {
return <ToolCallItem key={msg.id} message={msg} />