feat(web): session list, chat pane, and daemon client updates

This commit is contained in:
qer 2026-06-12 03:08:12 +08:00
parent a660aff5a5
commit affc69a0d3
24 changed files with 2676 additions and 138 deletions

File diff suppressed because it is too large Load diff

View file

@ -1045,6 +1045,40 @@ const server = http.createServer((req, res) => {
return res.end(ok(s));
}
// ---- sessions/{id}:compact ----
// Mirrors the real daemon: 200 {} immediately, then raw agent-core
// compaction.* frames over WS. After ~1.2s the stored history is rewritten
// to [summary message with origin compaction_summary] + recent suffix, so
// a later snapshot reload shows the compacted view like the real server.
if (seg[0] === 'sessions' && seg.length === 2 && method === 'POST' && seg[1].endsWith(':compact')) {
const realSid = seg[1].slice(0, -':compact'.length);
const session = sessions.find((s) => s.id === realSid);
if (!session) return res.end(fail(40401, `session ${realSid} does not exist`));
const msgs = messages[realSid] || [];
if (msgs.length < 2) return res.end(fail(40910, 'No prefix that can be compacted in current history.'));
const b = json();
const instruction = typeof b.instruction === 'string' && b.instruction.trim() ? b.instruction.trim() : undefined;
broadcast('compaction.started', realSid, { trigger: 'manual', instruction });
setTimeout(() => {
const all = messages[realSid] || [];
const keep = all.slice(-2);
const compactedCount = all.length - keep.length;
const summaryText =
'(stub) Summary of the earlier conversation: the user asked to make the API ' +
'client timeout configurable; the assistant edited createClient, ran the tests ' +
'(3 passed) and confirmed the change.' +
(instruction ? `\n\nInstruction honoured: ${instruction}` : '');
const summaryMsg = mkMsg(ulid('msg_'), realSid, 'assistant', [t(summaryText)], undefined);
summaryMsg.metadata = { origin: { kind: 'compaction_summary' } };
messages[realSid] = [summaryMsg, ...keep];
broadcast('compaction.completed', realSid, {
result: { summary: summaryText, compactedCount, tokensBefore: 90000, tokensAfter: 12000 },
});
}, 1200);
return res.end(ok({}));
}
// ---- sessions/{id} ----
if (seg[0] === 'sessions' && sid && seg.length === 2) {
const session = sessions.find((s) => s.id === sid);

View file

@ -0,0 +1,254 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Share Icon Options</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: #1a1a1a;
color: #e0e0e0;
padding: 40px;
display: flex;
flex-direction: column;
align-items: center;
gap: 24px;
}
h1 { font-size: 18px; font-weight: 500; color: #fff; margin-bottom: 8px; }
.subtitle { font-size: 13px; color: #888; margin-bottom: 16px; }
.grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 20px;
max-width: 720px;
width: 100%;
}
.card {
background: #252525;
border: 1px solid #333;
border-radius: 10px;
padding: 28px 20px;
display: flex;
flex-direction: column;
align-items: center;
gap: 14px;
cursor: pointer;
transition: border-color 0.15s, background 0.15s;
}
.card:hover {
border-color: #555;
background: #2a2a2a;
}
.card.active {
border-color: #4a9eff;
background: #1e2d3f;
}
.icon-wrap {
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
color: #888;
}
.card:hover .icon-wrap { color: #e0e0e0; }
.card.active .icon-wrap { color: #4a9eff; }
.label {
font-size: 12px;
color: #666;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.card.active .label { color: #4a9eff; }
.name {
font-size: 13px;
color: #aaa;
}
.hint {
font-size: 12px;
color: #555;
margin-top: 8px;
}
/* TabBar context preview */
.tabbar-preview {
width: 100%;
max-width: 720px;
background: #252525;
border: 1px solid #333;
border-radius: 8px;
height: 32px;
display: flex;
align-items: stretch;
justify-content: space-between;
overflow: hidden;
}
.tabbar-left {
display: flex;
align-items: stretch;
}
.tabbar-tab {
padding: 0 14px;
display: flex;
align-items: center;
gap: 6px;
font-size: 12.5px;
color: #666;
border-right: 1px solid #333;
cursor: default;
}
.tabbar-tab.on {
background: #1a1a1a;
color: #4a9eff;
font-weight: 600;
}
.tabbar-right {
display: flex;
align-items: center;
padding: 0 12px;
}
.tabbar-btn {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 4px;
background: transparent;
border: none;
border-radius: 4px;
color: #666;
font-size: 12px;
cursor: pointer;
transition: color 0.12s, background 0.12s;
}
.tabbar-btn:hover { color: #e0e0e0; background: #333; }
.tabbar-btn .btn-label {
opacity: 0;
max-width: 0;
overflow: hidden;
transition: opacity 0.15s ease, max-width 0.2s ease;
}
.tabbar-btn:hover .btn-label {
opacity: 1;
max-width: 120px;
}
</style>
</head>
<body>
<h1>选择分享图标</h1>
<p class="subtitle">点击卡片选中,下方会实时预览在 TabBar 中的效果</p>
<div class="grid" id="grid">
<!-- A -->
<div class="card" data-opt="A" onclick="select('A')">
<div class="icon-wrap">
<svg viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="4" r="1.3" fill="currentColor" stroke="none"/>
<circle cx="4" cy="8" r="1.3" fill="currentColor" stroke="none"/>
<circle cx="12" cy="12" r="1.3" fill="currentColor" stroke="none"/>
<path d="M5.3 7.2l5.4-2.4"/>
<path d="M5.3 8.8l5.4 2.4"/>
</svg>
</div>
<span class="label">A</span>
<span class="name">iOS/Android Share</span>
</div>
<!-- B -->
<div class="card" data-opt="B" onclick="select('B')">
<div class="icon-wrap">
<svg viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M8 11V3"/>
<path d="M4 6l4-3 4 3"/>
<path d="M2 13h12"/>
</svg>
</div>
<span class="label">B</span>
<span class="name">Arrow Up from Tray</span>
</div>
<!-- C -->
<div class="card" data-opt="C" onclick="select('C')">
<div class="icon-wrap">
<svg viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M6 2h8v8"/>
<path d="M14 2L2 14"/>
</svg>
</div>
<span class="label">C</span>
<span class="name">Arrow Out of Box</span>
</div>
<!-- D -->
<div class="card" data-opt="D" onclick="select('D')">
<div class="icon-wrap">
<svg viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M2 10c0-3.5 2.5-6 6-6"/>
<path d="M6 4l3 3-3 3"/>
</svg>
</div>
<span class="label">D</span>
<span class="name">Curved Forward</span>
</div>
<!-- E -->
<div class="card" data-opt="E" onclick="select('E')">
<div class="icon-wrap">
<svg viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M2 8l12-6-6 12-2-5-5-1z"/>
</svg>
</div>
<span class="label">E</span>
<span class="name">Paper Plane</span>
</div>
<!-- F -->
<div class="card" data-opt="F" onclick="select('F')">
<div class="icon-wrap">
<svg viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M8 3v8"/>
<path d="M4 9l4 4 4-4"/>
<path d="M2 15h12"/>
</svg>
</div>
<span class="label">F</span>
<span class="name">Export Arrow</span>
</div>
</div>
<p class="hint">下方是 TabBar 中的实际预览效果</p>
<div class="tabbar-preview">
<div class="tabbar-left">
<div class="tabbar-tab on">chat</div>
<div class="tabbar-tab">files</div>
<div class="tabbar-tab">tasks <span style="color:#666;font-size:11px;">2</span></div>
</div>
<div class="tabbar-right">
<button class="tabbar-btn" id="previewBtn">
<span id="previewIcon"></span>
<span class="btn-label">Share</span>
</button>
</div>
</div>
<script>
const icons = {
A: `<svg viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="4" r="1.3" fill="currentColor" stroke="none"/><circle cx="4" cy="8" r="1.3" fill="currentColor" stroke="none"/><circle cx="12" cy="12" r="1.3" fill="currentColor" stroke="none"/><path d="M5.3 7.2l5.4-2.4"/><path d="M5.3 8.8l5.4 2.4"/></svg>`,
B: `<svg viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M8 11V3"/><path d="M4 6l4-3 4 3"/><path d="M2 13h12"/></svg>`,
C: `<svg viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M6 2h8v8"/><path d="M14 2L2 14"/></svg>`,
D: `<svg viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2 10c0-3.5 2.5-6 6-6"/><path d="M6 4l3 3-3 3"/></svg>`,
E: `<svg viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2 8l12-6-6 12-2-5-5-1z"/></svg>`,
F: `<svg viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3v8"/><path d="M4 9l4 4 4-4"/><path d="M2 15h12"/></svg>`,
};
function select(opt) {
document.querySelectorAll('.card').forEach(c => c.classList.remove('active'));
document.querySelector(`.card[data-opt="${opt}"]`).classList.add('active');
document.getElementById('previewIcon').innerHTML = icons[opt];
}
select('A');
</script>
</body>
</html>

View file

@ -87,12 +87,24 @@ const sideWidth = computed(() => sessionColWidth.value);
// ---------------------------------------------------------------------------
const PREVIEW_WIDTH_KEY = 'kimi-web.file-preview-width';
const PREVIEW_MIN = 320;
const PREVIEW_MAX = 760;
const PREVIEW_DEFAULT = typeof window !== 'undefined'
? Math.max(PREVIEW_MIN, Math.min(PREVIEW_MAX, Math.round(window.innerWidth / 2)))
: 460;
const previewWidth = ref(PREVIEW_DEFAULT);
function previewAreaWidth(): number {
if (typeof window === 'undefined') return PREVIEW_MIN * 2;
return Math.max(0, window.innerWidth - sideWidth.value);
}
function clampPreviewWidth(width: number): number {
const max = Math.max(PREVIEW_MIN, previewAreaWidth() - PREVIEW_MIN);
return Math.min(max, Math.max(PREVIEW_MIN, Math.round(width)));
}
function defaultPreviewWidth(): number {
return clampPreviewWidth(previewAreaWidth() / 2);
}
const previewDefaultWidth = computed(() => defaultPreviewWidth());
const previewMaxWidth = computed(() => Math.max(PREVIEW_MIN, previewAreaWidth() - PREVIEW_MIN));
const previewWidth = ref(previewDefaultWidth.value);
const previewTarget = ref<FilePreviewRequest | null>(null);
const previewFile = ref<FileData | null>(null);
const previewLoading = ref(false);
@ -158,6 +170,7 @@ function normalizePreviewPath(inputPath: string): { path: string } | { error: st
async function openFilePreview(target: FilePreviewRequest): Promise<void> {
thinkingTarget.value = null; // shared right-side slot
compactionTarget.value = null;
const normalized = normalizePreviewPath(target.path);
previewTarget.value = target;
previewFile.value = null;
@ -193,6 +206,7 @@ function mimeFromDataUrl(url: string): string | undefined {
function openMediaPreview(media: ToolMedia): void {
if (media.kind !== 'image') return;
thinkingTarget.value = null;
compactionTarget.value = null;
previewRequestSeq++;
previewTarget.value = null;
previewError.value = null;
@ -243,6 +257,7 @@ function openThinkingPanel(target: { turnId: string; blockIndex: number }): void
return;
}
closeFilePreview();
compactionTarget.value = null;
thinkingTarget.value = target;
}
@ -250,8 +265,41 @@ function closeThinkingPanel(): void {
thinkingTarget.value = null;
}
/** Either occupant of the shared right-side slot. */
const sidePanelVisible = computed(() => previewVisible.value || thinkingVisible.value);
// ---------------------------------------------------------------------------
// Compaction summary panel shares the right-side slot too. Opened from a
// "context compacted" divider in the transcript; resolves the summary text
// reactively from the divider turn.
// ---------------------------------------------------------------------------
const compactionTarget = ref<{ turnId: string } | null>(null);
const compactionPanelText = computed<string | null>(() => {
const target = compactionTarget.value;
if (!target) return null;
const turn = client.turns.value.find((tn) => tn.id === target.turnId);
return turn?.role === 'compaction' && turn.text ? turn.text : null;
});
const compactionPanelVisible = computed(() => compactionPanelText.value !== null);
function openCompactionPanel(target: { turnId: string }): void {
// Clicking the SAME divider again closes the drawer (toggle).
if (compactionTarget.value?.turnId === target.turnId) {
compactionTarget.value = null;
return;
}
closeFilePreview();
thinkingTarget.value = null;
compactionTarget.value = target;
}
function closeCompactionPanel(): void {
compactionTarget.value = null;
}
/** Any occupant of the shared right-side slot. */
const sidePanelVisible = computed(
() => previewVisible.value || thinkingVisible.value || compactionPanelVisible.value,
);
/** True while the panel's resize handle is being dragged the width
transition is disabled so the panel follows the pointer 1:1. */
@ -272,6 +320,7 @@ function revealPreviewFile(): void {
watch(client.activeSessionId, () => {
closeFilePreview();
closeThinkingPanel();
closeCompactionPanel();
});
// Reference to ConversationPane so we can imperatively switch tabs
@ -376,6 +425,12 @@ async function handleLoginSuccess(): Promise<void> {
// Handler for slash commands emitted by Composer (via ConversationPane)
function handleCommand(cmd: string): void {
// `/compact <text>` carries an optional free-text instruction steering what
// the summary should focus on (TUI parity).
if (cmd === '/compact' || cmd.startsWith('/compact ')) {
client.compact(cmd.slice('/compact'.length).trim() || undefined);
return;
}
switch (cmd) {
case '/new':
case '/clear':
@ -384,9 +439,6 @@ function handleCommand(cmd: string): void {
case '/sessions':
showSessions.value = true;
break;
case '/compact':
client.compact();
break;
case '/fork':
void client.forkSession();
break;
@ -588,6 +640,7 @@ function handleCreateSessionInWorkspace(workspaceId: string): void {
@open-file="openFilePreview($event)"
@open-media="openMediaPreview($event)"
@open-thinking="openThinkingPanel($event)"
@open-compaction="openCompactionPanel($event)"
/>
<!-- Multi-workspace selection placeholder -->
@ -599,9 +652,9 @@ function handleCreateSessionInWorkspace(workspaceId: string): void {
<ResizeHandle
v-if="sidePanelVisible && !isMobile"
:storage-key="PREVIEW_WIDTH_KEY"
:default-width="PREVIEW_DEFAULT"
:default-width="previewDefaultWidth"
:min="PREVIEW_MIN"
:max="PREVIEW_MAX"
:max="previewMaxWidth"
reverse
:aria-label="t('layout.resizePreviewAria')"
@update:width="previewWidth = $event"
@ -623,6 +676,12 @@ function handleCreateSessionInWorkspace(workspaceId: string): void {
:text="thinkingPanelText ?? ''"
@close="closeThinkingPanel"
/>
<ThinkingPanel
v-else-if="compactionPanelVisible"
:text="compactionPanelText ?? ''"
:subtitle="t('conversation.summaryTitle')"
@close="closeCompactionPanel"
/>
<FilePreview
v-else-if="previewVisible"
:file="previewFile"

View file

@ -798,16 +798,17 @@ export function createAgentProjector(): AgentProjector {
// -----------------------------------------------------------------------
case 'compaction.completed': {
// Compaction replaced a batch of old messages with a summary on the
// daemon side. The in-memory transcript is now stale, so signal a reload.
// beforeSeq is patched to the real frame.seq by the client (the projector
// does not receive the wire seq); the client routes historyCompacted to
// onResync to refetch /messages.
// daemon side. The visible transcript is NOT reloaded (the client keeps
// the scrollback and the reducer appends a divider marker); the
// historyCompacted signal still fires so seq bookkeeping and any
// non-compaction consumers stay correct.
const result = (p?.result ?? {}) as Record<string, unknown>;
out.push({
type: 'compactionCompleted',
sessionId,
tokensBefore: typeof result.tokensBefore === 'number' ? result.tokensBefore : undefined,
tokensAfter: typeof result.tokensAfter === 'number' ? result.tokensAfter : undefined,
summary: typeof result.summary === 'string' ? result.summary : undefined,
});
out.push({
type: 'historyCompacted',

View file

@ -175,6 +175,16 @@ interface WireDiffResult {
diff: string;
}
/**
* historyCompacted reasons caused by compaction itself. These do NOT trigger a
* snapshot reload: the client keeps the visible scrollback and renders a
* divider marker instead. Every other reason (delta_gap, history_rewrite, )
* still means "cached messages are stale" and goes through onResync.
*/
function isCompactionReason(reason: string): boolean {
return reason === 'auto_compact' || reason === 'manual_compact';
}
// ---------------------------------------------------------------------------
// DaemonKimiWebApi
// ---------------------------------------------------------------------------
@ -433,8 +443,9 @@ export class DaemonKimiWebApi implements KimiWebApi {
return { aborted: data.aborted, atSeq: data.at_seq };
}
// POST /sessions/{id}:compact — request history compaction. Returns {}; the
// compacted history arrives via the WS history_compacted → onResync reload.
// POST /sessions/{id}:compact — request history compaction. Returns {};
// progress and completion arrive via the WS compaction.* events (the
// transcript itself is not reloaded — a divider marker is appended).
async compactSession(sessionId: string, instruction?: string): Promise<void> {
await this.http.post(
`/sessions/${encodeURIComponent(sessionId)}:compact`,
@ -962,8 +973,11 @@ export class DaemonKimiWebApi implements KimiWebApi {
const seq = wireEventSeq(wireEvent);
const appEvent = toAppEvent(wireEvent);
// Route history_compacted to onResync so client can reload messages
if (appEvent.type === 'historyCompacted') {
// Route history_compacted to onResync so the client reloads messages —
// EXCEPT for compaction itself: the transcript keeps the scrollback and
// the reducer appends a divider marker instead (reloading would replace
// the visible conversation with the compacted model context).
if (appEvent.type === 'historyCompacted' && !isCompactionReason(appEvent.reason)) {
handlers.onResync(appEvent.sessionId, appEvent.beforeSeq);
// Still dispatch the event to onEvent so the reducer can update lastSeqBySession
}
@ -980,10 +994,11 @@ export class DaemonKimiWebApi implements KimiWebApi {
const { type, seq, session_id: sessionId, payload, offset } = frame;
const appEvents = projector.project(type, payload, sessionId, { offset });
for (const appEvent of appEvents) {
// Auto-compaction: the projector can't see the wire seq, so it emits
// historyCompacted with beforeSeq:0. Route it to onResync using the
// real frame.seq to reload /messages, mirroring the protocol path.
if (appEvent.type === 'historyCompacted') {
// historyCompacted from the projector is either a compaction signal
// (reason auto_compact — no reload, the divider marker handles it) or
// a delta-gap recovery (reason delta_gap — a real resync, routed to
// onResync with the real frame.seq, mirroring the protocol path).
if (appEvent.type === 'historyCompacted' && !isCompactionReason(appEvent.reason)) {
handlers.onResync(sessionId, seq);
}
handlers.onEvent(appEvent, { sessionId, seq });

View file

@ -16,7 +16,9 @@ import type {
AppQuestionRequest,
AppSession,
AppTask,
CompactionMarkerMetadata,
} from '../types';
import { COMPACTION_MARKER_METADATA_KEY } from '../types';
import { i18n } from '../../i18n';
const OPTIMISTIC_USER_MESSAGE_METADATA_KEY = 'kimiWeb.optimisticUserMessage';
@ -25,13 +27,12 @@ const OPTIMISTIC_USER_MESSAGE_METADATA_KEY = 'kimiWeb.optimisticUserMessage';
// State
// ---------------------------------------------------------------------------
/** Live compaction progress for a session (TUI parity: "Compacting context"
while running, then a one-shot "complete (X → Y tokens)" note). */
/** Live compaction progress for a session: present (status 'running') only
while the daemon is compacting. Completion is recorded as a persistent
divider marker message in the transcript, not as transient status. */
export interface CompactionStatus {
status: 'running' | 'completed';
status: 'running';
trigger: 'manual' | 'auto';
tokensBefore?: number;
tokensAfter?: number;
}
export interface KimiClientState {
@ -224,16 +225,41 @@ export function reduceAppEvent(
}
case 'compactionCompleted': {
const prev = next.compactionBySession[event.sessionId];
next.compactionBySession = {
...next.compactionBySession,
[event.sessionId]: {
status: 'completed',
trigger: prev?.trigger ?? 'auto',
tokensBefore: event.tokensBefore,
tokensAfter: event.tokensAfter,
},
};
const sid = event.sessionId;
const prev = next.compactionBySession[sid];
const { [sid]: _doneEntry, ...rest } = next.compactionBySession;
next.compactionBySession = rest;
// Append a persistent "context compacted" divider to the loaded
// transcript (TUI parity: the scrollback is kept untouched; only a
// one-line marker records that compaction happened). The marker id is
// derived from the wire seq so an event replay after reconnect can't
// duplicate it.
if (Object.prototype.hasOwnProperty.call(next.messagesBySession, sid)) {
const msgs = next.messagesBySession[sid] ?? [];
const markerId = `compaction_${sid}_${meta.seq}`;
if (!msgs.some((m) => m.id === markerId)) {
const marker: CompactionMarkerMetadata = {
trigger: prev?.trigger ?? 'auto',
tokensBefore: event.tokensBefore,
tokensAfter: event.tokensAfter,
};
next.messagesBySession[sid] = [
...msgs,
{
id: markerId,
sessionId: sid,
role: 'assistant',
content: event.summary ? [{ type: 'text', text: event.summary }] : [],
createdAt: new Date().toISOString(),
metadata: {
origin: { kind: 'compaction_summary' },
[COMPACTION_MARKER_METADATA_KEY]: marker,
},
},
];
}
}
break;
}

View file

@ -142,6 +142,21 @@ export interface AppMessage {
metadata?: Record<string, unknown>;
}
/**
* Metadata key of the client-side compaction marker message appended on
* compactionCompleted. The transcript keeps all prior messages (TUI parity);
* this marker renders as a "context compacted" divider. Snapshot-loaded
* summary messages (origin kind 'compaction_summary') render the same way
* but carry no token stats.
*/
export const COMPACTION_MARKER_METADATA_KEY = 'kimiWeb.compaction';
export interface CompactionMarkerMetadata {
trigger: 'manual' | 'auto';
tokensBefore?: number;
tokensAfter?: number;
}
// ---------------------------------------------------------------------------
// Prompt
// ---------------------------------------------------------------------------
@ -290,7 +305,7 @@ export type AppEvent =
| { type: 'sessionUsageUpdated'; sessionId: string; usage: AppSessionUsage; model?: string }
| { type: 'historyCompacted'; sessionId: string; beforeSeq: number; reason: string; summaryMessageId?: string }
| { type: 'compactionStarted'; sessionId: string; trigger: 'manual' | 'auto'; instruction?: string }
| { type: 'compactionCompleted'; sessionId: string; tokensBefore?: number; tokensAfter?: number }
| { type: 'compactionCompleted'; sessionId: string; tokensBefore?: number; tokensAfter?: number; summary?: string }
| { type: 'compactionCancelled'; sessionId: string }
| { type: 'messageCreated'; message: AppMessage }
| { type: 'messageUpdated'; sessionId: string; messageId: string; content: AppMessageContent[]; status: 'pending' | 'completed' | 'error' }

View file

@ -0,0 +1,66 @@
<!-- apps/kimi-web/src/components/ActivityNotice.vue -->
<!-- Generic in-transcript "working on X" notice: the moon-phase spinner plus a
body-sized label. Used for long-running session activities that are not a
chat turn (e.g. "Compacting context…"). Renders inline at the end of the
transcript in both the bubble and line layouts. -->
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue';
defineProps<{
label: string;
}>();
const MOON_FRAMES = ['🌑', '🌒', '🌓', '🌔', '🌕', '🌖', '🌗', '🌘'];
const MOON_INTERVAL_MS = 120;
const moonFrame = ref(0);
let moonInterval: ReturnType<typeof setInterval> | null = null;
onMounted(() => {
moonInterval = setInterval(() => {
moonFrame.value = (moonFrame.value + 1) % MOON_FRAMES.length;
}, MOON_INTERVAL_MS);
});
onUnmounted(() => {
if (moonInterval) {
clearInterval(moonInterval);
moonInterval = null;
}
});
</script>
<template>
<div class="activity-notice" role="status">
<span class="an-moon" aria-hidden="true">{{ MOON_FRAMES[moonFrame] }}</span>
<span class="an-label">{{ label }}</span>
</div>
</template>
<style scoped>
/* Same size as assistant body text (.a-msg .msg / Markdown) so the notice
reads as part of the conversation, not as chrome. */
.activity-notice {
display: flex;
align-items: center;
gap: 8px;
align-self: flex-start;
margin: 6px 0;
font-size: 14px;
line-height: 1.6;
color: var(--ink);
}
.an-moon {
font-size: 14px;
line-height: 1;
user-select: none;
}
/* Mobile font bump (+2px), matching ChatPane's body text. */
@media (max-width: 640px) {
.activity-notice,
.an-moon {
font-size: 16px;
}
}
</style>

View file

@ -10,6 +10,7 @@ import ToolCall from './ToolCall.vue';
import ApprovalCard from './ApprovalCard.vue';
import Markdown from './Markdown.vue';
import ThinkingBlock from './ThinkingBlock.vue';
import ActivityNotice from './ActivityNotice.vue';
const MOON_FRAMES = ['🌑', '🌒', '🌓', '🌔', '🌕', '🌖', '🌗', '🌘'];
@ -74,11 +75,11 @@ const props = withDefaults(
*/
sessionLoading?: boolean;
/**
* Live compaction state of the session: a "compacting…" banner while the
* daemon rewrites history, then a transient "compacted (X → Y tokens)" note
* (auto-dismissed by the composable).
* Live compaction state of the session: non-null while the daemon rewrites
* history, rendered as a body-sized "Compacting context…" activity notice.
* Completion is a persistent divider turn (role 'compaction') in `turns`.
*/
compaction?: { status: 'running' | 'completed'; tokensBefore?: number; tokensAfter?: number } | null;
compaction?: { status: 'running' } | null;
/**
* @deprecated No longer used Composer is rendered by ConversationPane.
*/
@ -106,17 +107,32 @@ const emit = defineEmits<{
copyConversationCopied: [];
/** Show a thinking block's full text in the right-side panel. */
openThinking: [target: { turnId: string; blockIndex: number }];
/** Show a compaction divider's summary text in the right-side panel. */
openCompaction: [target: { turnId: string }];
}>();
const compactionLabel = computed<string>(() => {
const c = props.compaction;
if (!c) return '';
if (c.status === 'running') return t('conversation.compacting');
if (typeof c.tokensBefore === 'number' && typeof c.tokensAfter === 'number') {
return t('conversation.compacted', { before: c.tokensBefore, after: c.tokensAfter });
function formatTokens(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
if (n >= 1000) return `${(n / 1000).toFixed(1)}k`;
return String(n);
}
/** Divider label: "Context compacted"/"auto-compacted" + optional token stats. */
function compactionDividerLabel(turn: ChatTurn): string {
const c = turn.compaction;
const base =
c?.trigger === 'auto' ? t('conversation.compactedAuto') : t('conversation.compactedPlain');
if (typeof c?.tokensBefore === 'number' && typeof c?.tokensAfter === 'number') {
return (
base +
t('conversation.compactedTokens', {
before: formatTokens(c.tokensBefore),
after: formatTokens(c.tokensAfter),
})
);
}
return t('conversation.compactedPlain');
});
return base;
}
// Per-turn copy button state (keyed by turn id)
const copiedTurn = ref<string | null>(null);
@ -160,6 +176,7 @@ function copyConversation(): void {
if (props.turns.length === 0) return;
const lines: string[] = [];
for (const turn of props.turns) {
if (turn.role === 'compaction') continue; // dividers don't copy
const roleLabel = turn.role === 'user' ? 'User' : 'Assistant';
const content = turnToMarkdown(turn);
if (content.trim()) {
@ -265,6 +282,23 @@ function turnBlocks(turn: ChatTurn): TurnBlock[] {
<div class="u-text">{{ turn.text }}</div>
</div>
<!-- Compaction divider prior turns stay untouched; summary opens in
the right-side panel on click. -->
<div v-else-if="turn.role === 'compaction'" class="compact-divider" role="separator">
<span class="cd-line" aria-hidden="true" />
<button
v-if="turn.text"
type="button"
class="cd-label cd-btn"
@click="emit('openCompaction', { turnId: turn.id })"
>
<span>{{ compactionDividerLabel(turn) }}</span>
<span class="cd-view">{{ t('conversation.viewSummary') }}</span>
</button>
<span v-else class="cd-label">{{ compactionDividerLabel(turn) }}</span>
<span class="cd-line" aria-hidden="true" />
</div>
<!-- Assistant turn left-aligned, no name/role label. -->
<div v-else class="a-msg">
<template v-for="(blk, bi) in turnBlocks(turn)" :key="bi">
@ -299,11 +333,8 @@ function turnBlocks(turn: ChatTurn): TurnBlock[] {
@decide="(response) => emit('approvalDecide', a.approvalId, response)"
/>
<!-- Compaction banner running ("compacting…") or transient done note -->
<div v-if="compaction" class="compaction-note" :class="compaction.status">
<span v-if="compaction.status === 'running'" class="dot-pulse" aria-hidden="true" />
<span>{{ compactionLabel }}</span>
</div>
<!-- Compaction in progress body-sized moon activity notice -->
<ActivityNotice v-if="compaction" :label="t('conversation.compacting')" />
<!-- Sending placeholder moon spinner while the request is in flight -->
<div v-if="sending" class="sending-placeholder">
@ -323,7 +354,23 @@ function turnBlocks(turn: ChatTurn): TurnBlock[] {
<div v-else-if="turns.length === 0 && (!approvals || approvals.length === 0)" class="chat-empty" />
<template v-for="(turn, ti) in turns" :key="turn.id">
<div class="ln" :class="turn.role === 'user' ? 'userline' : 'ai'">
<!-- Compaction divider full-width separator, no gutter number. -->
<div v-if="turn.role === 'compaction'" class="compact-divider" role="separator">
<span class="cd-line" aria-hidden="true" />
<button
v-if="turn.text"
type="button"
class="cd-label cd-btn"
@click="emit('openCompaction', { turnId: turn.id })"
>
<span>{{ compactionDividerLabel(turn) }}</span>
<span class="cd-view">{{ t('conversation.viewSummary') }}</span>
</button>
<span v-else class="cd-label">{{ compactionDividerLabel(turn) }}</span>
<span class="cd-line" aria-hidden="true" />
</div>
<div v-else class="ln" :class="turn.role === 'user' ? 'userline' : 'ai'">
<!-- Line-number gutter -->
<span class="no">{{ turn.no }}</span>
@ -377,11 +424,8 @@ function turnBlocks(turn: ChatTurn): TurnBlock[] {
@decide="(response) => emit('approvalDecide', a.approvalId, response)"
/>
<!-- Compaction banner running ("compacting…") or transient done note -->
<div v-if="compaction" class="compaction-note" :class="compaction.status">
<span v-if="compaction.status === 'running'" class="dot-pulse" aria-hidden="true" />
<span>{{ compactionLabel }}</span>
</div>
<!-- Compaction in progress body-sized moon activity notice -->
<ActivityNotice v-if="compaction" :label="t('conversation.compacting')" />
<!-- Sending placeholder moon spinner while the request is in flight -->
<div v-if="sending" class="ln sending-line">
@ -525,18 +569,43 @@ function turnBlocks(turn: ChatTurn): TurnBlock[] {
overflow-wrap: anywhere;
}
/* Compaction banner — "compacting…" while running, transient done note after. */
.compaction-note {
/* Compaction divider a full-width separator marking where the daemon
compacted the context. Prior turns above it are untouched; clicking the
label opens the summary in the right-side panel. */
.compact-divider {
display: flex;
align-items: center;
gap: 8px;
align-self: flex-start;
margin: 6px 0;
font-size: 12.5px;
font-family: var(--mono);
color: var(--faint);
gap: 10px;
align-self: stretch;
width: 100%;
margin: 14px 0;
}
.compaction-note.completed { color: var(--ok); }
.cd-line {
flex: 1;
height: 1px;
background: var(--line);
}
.cd-label {
flex: none;
display: inline-flex;
align-items: center;
gap: 8px;
max-width: 80%;
font-size: 12.5px;
color: var(--muted);
white-space: nowrap;
}
.cd-btn {
background: none;
border: none;
padding: 0;
cursor: pointer;
font: inherit;
font-size: 12.5px;
color: var(--muted);
}
.cd-view { color: var(--blue); }
.cd-btn:hover .cd-view { text-decoration: underline; }
/* Assistant message → left-aligned plain column, no role label */
.a-msg {
@ -679,5 +748,9 @@ function turnBlocks(turn: ChatTurn): TurnBlock[] {
.chat-loading-text {
font-size: 15px;
}
.cd-label,
.cd-btn {
font-size: 14px;
}
}
</style>

View file

@ -367,14 +367,15 @@ function handleSubmit(): void {
if (!trimmed && readyAttachments.length === 0) return;
// If it's a slash command (no space treat as command trigger)
// If it's a slash command (no space treat as command trigger).
// /compact also accepts a free-text instruction after the command, so it
// stays a command instead of falling through as a chat message.
if (trimmed) {
const parsed = parseSlash(trimmed);
if (parsed && !parsed.arg) {
// pure command with no extra text
if (parsed && (!parsed.arg || parsed.cmd === '/compact')) {
text.value = '';
slashOpen.value = false;
emit('command', parsed.cmd);
emit('command', parsed.arg ? `${parsed.cmd} ${parsed.arg}` : parsed.cmd);
return;
}
}

View file

@ -54,8 +54,8 @@ const props = defineProps<{
modern?: boolean;
/** True while switching sessions and the turns array is not yet loaded. */
sessionLoading?: boolean;
/** Live compaction state of the active session (running banner / done note). */
compaction?: { status: 'running' | 'completed'; tokensBefore?: number; tokensAfter?: number } | null;
/** Live compaction state of the active session (non-null while running). */
compaction?: { status: 'running' } | null;
/** Available models for the quick-switch dropdown in the composer toolbar. */
models?: AppModel[];
/** Workspace name shown in the empty-session hint above the centred composer. */
@ -82,6 +82,7 @@ const emit = defineEmits<{
openFile: [target: FilePreviewRequest];
openMedia: [media: ToolMedia];
openThinking: [target: { turnId: string; blockIndex: number }];
openCompaction: [target: { turnId: string }];
}>();
const { t } = useI18n();
@ -633,6 +634,7 @@ onUnmounted(() => {
@open-media="emit('openMedia', $event)"
@copy-conversation-copied="handleCopyConversationCopied"
@open-thinking="emit('openThinking', $event)"
@open-compaction="emit('openCompaction', $event)"
/>
</template>
</div>

View file

@ -248,14 +248,16 @@ watch(
<!-- Name -->
<span class="ft-name">{{ node.entry.name }}</span>
<!-- Git status badge -->
<!-- Git status badge: prefer the entry's own gitStatus (available
immediately from listDirectory with includeGitStatus=true) and
fall back to the per-session changesByPath map. -->
<span
v-if="changesByPath[node.entry.path]"
v-if="node.entry.gitStatus ?? changesByPath[node.entry.path]"
class="ft-badge"
:class="badgeKind(changesByPath[node.entry.path]!)"
:title="changesByPath[node.entry.path]"
:class="badgeKind(node.entry.gitStatus ?? changesByPath[node.entry.path]!)"
:title="node.entry.gitStatus ?? changesByPath[node.entry.path]"
>
{{ badgeGlyph(changesByPath[node.entry.path]!) }}
{{ badgeGlyph(node.entry.gitStatus ?? changesByPath[node.entry.path]!) }}
</span>
</div>
</template>

View file

@ -9,6 +9,9 @@ import { useI18n } from 'vue-i18n';
const props = defineProps<{
text: string;
/** Header label override defaults to the thinking panel title. Lets the
panel double as the compaction-summary viewer. */
subtitle?: string;
}>();
const emit = defineEmits<{
@ -37,7 +40,7 @@ watch(
<div class="tp">
<div class="tp-header">
<span class="tp-title">{{ t('common.preview') }}</span>
<span class="tp-sub">{{ t('thinking.panelTitle') }}</span>
<span class="tp-sub">{{ subtitle ?? t('thinking.panelTitle') }}</span>
<button type="button" class="tp-close" :title="t('thinking.close')" @click="emit('close')">
<svg viewBox="0 0 12 12" width="11" height="11" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" aria-hidden="true"><line x1="2" y1="2" x2="10" y2="10"/><line x1="10" y1="2" x2="2" y2="10"/></svg>
</button>

View file

@ -10,7 +10,8 @@
// Fallback: if promptId is undefined on both the pending group and the
// incoming message they are NOT merged (one turn per message, old behaviour).
import type { AppMessage, AppApprovalRequest } from '../api/types';
import type { AppMessage, AppApprovalRequest, CompactionMarkerMetadata } from '../api/types';
import { COMPACTION_MARKER_METADATA_KEY } from '../api/types';
import type { ApprovalBlock, ChatTurn, DiffLine, ToolCall, ToolMedia, TurnBlock } from '../types';
const READ_MEDIA_TOOL_RE = /^read[_-]?media(?:file)?$/i;
@ -285,6 +286,17 @@ function isDisplayableUserMessage(msg: AppMessage): boolean {
return false;
}
/**
* A compaction summary message either the client-side marker appended on
* compactionCompleted, or the daemon's synthetic ASSISTANT message that
* replaces the compacted prefix in a reloaded snapshot. Both render as a
* "context compacted" divider; the summary text opens in the side panel.
*/
function isCompactionSummaryMessage(msg: AppMessage): boolean {
const origin = msg.metadata?.['origin'] as { kind?: string } | undefined;
return origin?.kind === 'compaction_summary';
}
export function messagesToTurns(
messages: AppMessage[],
approvals: AppApprovalRequest[],
@ -404,6 +416,30 @@ export function messagesToTurns(
for (const msg of messages) {
if (msg.role === 'system') continue;
// Compaction summaries become a divider turn — never a chat bubble. The
// snapshot variant carries no token stats (marker metadata is client-side).
if (isCompactionSummaryMessage(msg)) {
flushGroup();
const marker = msg.metadata?.[COMPACTION_MARKER_METADATA_KEY] as
| CompactionMarkerMetadata
| undefined;
turns.push({
id: msg.id,
role: 'compaction',
no, // not displayed — dividers have no gutter number
text: msg.content
.filter((c): c is { type: 'text'; text: string } => c.type === 'text')
.map((c) => c.text)
.join('\n'),
compaction: {
trigger: marker?.trigger,
tokensBefore: marker?.tokensBefore,
tokensAfter: marker?.tokensAfter,
},
});
continue;
}
// User messages flush the pending group and start a new user turn
if (msg.role === 'user') {
flushGroup();

View file

@ -480,27 +480,6 @@ function applyEvent(event: ReturnType<typeof toAppEvent>, sessionId: string, seq
rawState.warnings = next.warnings;
}
// Auto-dismiss the "compaction complete (X → Y tokens)" note a few seconds
// after it appears. One timer per session; restarted on every completion.
const compactionClearTimers = new Map<string, ReturnType<typeof setTimeout>>();
const COMPACTION_NOTE_MS = 5000;
function scheduleCompactionNoteDismiss(sessionId: string): void {
const existing = compactionClearTimers.get(sessionId);
if (existing !== undefined) clearTimeout(existing);
compactionClearTimers.set(
sessionId,
setTimeout(() => {
compactionClearTimers.delete(sessionId);
const entry = rawState.compactionBySession[sessionId];
if (entry?.status === 'completed') {
const { [sessionId]: _gone, ...rest } = rawState.compactionBySession;
rawState.compactionBySession = rest;
}
}, COMPACTION_NOTE_MS),
);
}
// ---------------------------------------------------------------------------
// WS subscription (lazy, only when a session is selected)
// ---------------------------------------------------------------------------
@ -517,17 +496,11 @@ function connectEventsIfNeeded(): void {
eventConn = api.connectEvents({
onEvent(appEvent, meta) {
// meta carries wire-level seq/sessionId so the reducer can advance
// lastSeqBySession[sessionId] = seq. historyCompacted reload is owned
// by onResync (the client routes it there); here we only let the reducer
// run so lastSeqBySession stays current.
// lastSeqBySession[sessionId] = seq. Compaction completion appends a
// persistent divider marker in the reducer (TUI parity: the scrollback
// is kept, only a marker line records the compaction).
applyEvent(appEvent, meta.sessionId, meta.seq);
// Compaction completion note self-dismisses (TUI keeps a transcript
// line; the web shows a transient banner instead).
if (appEvent.type === 'compactionCompleted') {
scheduleCompactionNoteDismiss(appEvent.sessionId);
}
// Turn-end cleanup for the session the event belongs to — including
// sessions running in the background (see onSessionIdle).
if (
@ -943,7 +916,7 @@ const todos = computed<TodoView[]>(() => {
return latestTodos(rawState.messagesBySession[sid] ?? []);
});
/** Live compaction state of the active session (running banner / done note). */
/** Live compaction state of the active session (present only while running). */
const compaction = computed<CompactionStatus | null>(() => {
const sid = rawState.activeSessionId;
if (!sid) return null;
@ -1735,8 +1708,6 @@ async function selectSession(
if (rawState.activeWorkspaceId !== wid) selectWorkspace(wid);
}
refreshSessionSidecars(sessionId);
if (!messagesLoaded) {
// First open: full snapshot → seed → subscribe(asOfSeq).
const result = await syncSessionFromSnapshot(sessionId);
@ -1746,6 +1717,10 @@ async function selectSession(
// missed durable events (or answers resync_required → snapshot).
subscribeToSessionEvents(sessionId);
}
// Refresh sidecars AFTER the snapshot settles so status/usage updates
// aren't overwritten by syncSessionFromSnapshot.
refreshSessionSidecars(sessionId);
} catch (err) {
rawState.warnings = [...rawState.warnings, `selectSession failed: ${String(err)}`];
} finally {
@ -2358,15 +2333,16 @@ async function logout(): Promise<void> {
}
/**
* compact() request history compaction via POST /sessions/{id}:compact. The
* compacted history is delivered asynchronously through the WS
* history_compacted onResync reload, so we just fire the request.
* compact() request history compaction via POST /sessions/{id}:compact.
* Progress arrives asynchronously through the WS compaction.* events (running
* notice divider marker), so we just fire the request. An optional
* instruction (from `/compact <text>`) steers what the summary focuses on.
*/
function compact(): void {
function compact(instruction?: string): void {
const sid = rawState.activeSessionId;
if (!sid) return;
void getKimiWebApi()
.compactSession(sid)
.compactSession(sid, instruction)
.catch((err) => {
rawState.warnings = [...rawState.warnings, `compact failed: ${String(err)}`];
});

View file

@ -13,7 +13,6 @@ export default {
thinking: { desc: 'Set the thinking level' },
compact: { desc: 'Compact the conversation history' },
fork: { desc: 'Fork this session into a new one' },
compactNotImplemented: '/compact is not connected yet',
undoNotImplemented: '/undo isn\'t supported by the daemon yet',
status: { desc: 'View session status' },
undo: { desc: 'Undo the last message' },

View file

@ -4,7 +4,10 @@ export default {
loading: 'Loading…',
emptyWorkspaceHint: 'Send in {name}',
compacting: 'Compacting context…',
compacted: 'Context compacted ({before} → {after} tokens)',
compactedPlain: 'Context compacted',
compactedAuto: 'Context auto-compacted',
compactedTokens: ' ({before} → {after} tokens)',
viewSummary: 'View summary',
summaryTitle: 'Compaction summary',
manuallyAborted: 'Manually stopped',
} as const;

View file

@ -13,7 +13,6 @@ export default {
thinking: { desc: '设置思考强度' },
compact: { desc: '压缩会话历史' },
fork: { desc: '把当前会话 fork 出一个新会话' },
compactNotImplemented: '尚未接入 /compact',
undoNotImplemented: 'daemon 尚未支持 /undo',
status: { desc: '查看会话状态' },
undo: { desc: '撤销上一条消息' },

View file

@ -4,7 +4,10 @@ export default {
loading: '加载中…',
emptyWorkspaceHint: '在 {name} 中发送',
compacting: '正在压缩上下文…',
compacted: '上下文已压缩({before} → {after} tokens',
compactedPlain: '上下文已压缩',
compactedAuto: '已自动压缩上下文',
compactedTokens: '{before} → {after} tokens',
viewSummary: '查看摘要',
summaryTitle: '压缩摘要',
manuallyAborted: '您已手动终止',
} as const;

View file

@ -110,7 +110,7 @@ export type ApprovalBlock =
| { kind: 'todo'; items: { title: string; status: string }[] }
| { kind: 'generic'; summary: string };
export type TurnRole = 'user' | 'assistant';
export type TurnRole = 'user' | 'assistant' | 'compaction';
export interface FilePreviewRequest {
path: string;
@ -140,6 +140,10 @@ export interface ChatTurn {
approvalId?: string; // daemon approval id — present when approval needs a decision
/** Image attachments sent by the user (rendered above the text bubble). */
images?: { url: string; alt?: string }[];
/** Compaction divider data (role 'compaction'): the transcript keeps all
prior turns and renders this as a separator line; `text` holds the
LLM-generated summary, opened in the right-side panel on click. */
compaction?: { trigger?: 'manual' | 'auto'; tokensBefore?: number; tokensAfter?: number };
}
/**

View file

@ -0,0 +1,42 @@
// TEMP repro — markstream-vue rendering of a real chat message (delete after).
import { describe, it } from 'vitest';
import { readFileSync, writeFileSync } from 'node:fs';
import { mount } from '@vue/test-utils';
import { MarkdownRender } from 'markstream-vue';
const text = readFileSync('/tmp/md-bug-sample.md', 'utf8');
describe('markstream repro', () => {
it('streams the sample like the app (chunked, smooth-streaming)', async () => {
const w = mount(MarkdownRender, {
props: { content: '', mode: 'chat', final: false, smoothStreaming: true },
});
// Feed in small chunks like assistant deltas
let acc = '';
for (let i = 0; i < text.length; i += 24) {
acc = text.slice(0, i + 24);
await w.setProps({ content: acc });
await new Promise((r) => setTimeout(r, 1));
}
await w.setProps({ content: text, final: true, smoothStreaming: false });
await new Promise((r) => setTimeout(r, 1500));
writeFileSync('/tmp/md-repro-stream.html', w.html());
});
it('renders with shiki code renderer (app config)', async () => {
const w = mount(MarkdownRender, {
props: {
content: text,
mode: 'chat',
final: true,
codeRenderer: 'shiki',
isDark: false,
codeBlockLightTheme: 'github-light',
codeBlockDarkTheme: 'github-dark',
themes: ['github-light', 'github-dark'],
},
});
await new Promise((r) => setTimeout(r, 3000));
writeFileSync('/tmp/md-repro-shiki.html', w.html());
});
});

View file

@ -1,19 +1,22 @@
// apps/kimi-web/test/compaction.test.ts
//
// Compaction events stream through the REAL pipeline — projector → reducer —
// and surface as per-session compaction status (running banner → done note),
// plus the historyCompacted reload signal on completion.
// and surface as per-session compaction status ("compacting…" notice while
// running) plus a persistent divider marker message on completion. The
// scrollback is never reloaded/replaced.
import { describe, expect, it } from 'vitest';
import { createAgentProjector } from '../src/api/daemon/agentEventProjector';
import { createInitialState, reduceAppEvent, type KimiClientState } from '../src/api/daemon/eventReducer';
import type { AppEvent } from '../src/api/types';
import { COMPACTION_MARKER_METADATA_KEY, type AppEvent } from '../src/api/types';
const SESSION = 'sess_1';
function play(events: [string, unknown][]): { state: KimiClientState; appEvents: AppEvent[] } {
const projector = createAgentProjector();
let state = createInitialState();
// The session transcript is loaded (the marker is only appended then).
state = { ...state, messagesBySession: { [SESSION]: [] } };
const appEvents: AppEvent[] = [];
let seq = 0;
for (const [type, payload] of events) {
@ -36,19 +39,27 @@ describe('compaction pipeline', () => {
});
});
it('compaction.completed flips to a done note with token counts and signals a reload', () => {
it('compaction.completed clears the running status and appends a divider marker', () => {
const { state, appEvents } = play([
['compaction.started', { trigger: 'auto' }],
['compaction.completed', { result: { summary: 's', compactedCount: 12, tokensBefore: 90000, tokensAfter: 12000 } }],
]);
expect(state.compactionBySession[SESSION]).toEqual({
status: 'completed',
// Running status is gone — completion is the marker, not transient status.
expect(state.compactionBySession[SESSION]).toBeUndefined();
const msgs = state.messagesBySession[SESSION] ?? [];
const marker = msgs[msgs.length - 1];
expect(marker?.metadata?.['origin']).toEqual({ kind: 'compaction_summary' });
expect(marker?.metadata?.[COMPACTION_MARKER_METADATA_KEY]).toEqual({
trigger: 'auto',
tokensBefore: 90000,
tokensAfter: 12000,
});
// The reload signal must still fire (client routes it to onResync).
expect(marker?.content).toEqual([{ type: 'text', text: 's' }]);
// The historyCompacted signal still fires (seq bookkeeping); the client
// wrapper must NOT route compaction reasons to a snapshot reload.
expect(appEvents.some((e) => e.type === 'historyCompacted')).toBe(true);
});
@ -60,12 +71,14 @@ describe('compaction pipeline', () => {
expect(state.compactionBySession[SESSION]).toBeUndefined();
});
it('a completed event without a prior started still produces a done note', () => {
it('a completed event without a prior started still appends a marker', () => {
const { state } = play([
['compaction.completed', { result: { summary: 's', compactedCount: 3, tokensBefore: 50000, tokensAfter: 8000 } }],
]);
expect(state.compactionBySession[SESSION]).toMatchObject({
status: 'completed',
expect(state.compactionBySession[SESSION]).toBeUndefined();
const msgs = state.messagesBySession[SESSION] ?? [];
expect(msgs[msgs.length - 1]?.metadata?.[COMPACTION_MARKER_METADATA_KEY]).toMatchObject({
trigger: 'auto',
tokensBefore: 50000,
tokensAfter: 8000,
});

View file

@ -353,6 +353,11 @@ export class SessionService extends Disposable implements ISessionService {
throw new SessionNotFoundError(id);
}
// beginCompaction only sees sessions loaded in core memory — resume first
// (mirrors undo) so compacting a freshly-opened session doesn't throw
// SESSION_NOT_FOUND.
await this.core.rpc.resumeSession({ sessionId: id });
const instruction = normalizeOptionalString(input.instruction);
await this.core.rpc.beginCompaction({
sessionId: id,