From 82419eaad60d4618fa2e8d644a968a54468f9aa6 Mon Sep 17 00:00:00 2001 From: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> Date: Sat, 7 Feb 2026 12:37:22 -0600 Subject: [PATCH] Web UI: show Compaction divider in chat history (#11341) --- src/gateway/session-utils.fs.test.ts | 48 ++++++++++++++++++++++++++++ src/gateway/session-utils.fs.ts | 17 ++++++++++ ui/src/styles/chat/grouped.css | 27 ++++++++++++++++ ui/src/ui/types/chat-types.ts | 1 + ui/src/ui/views/chat.ts | 24 ++++++++++++++ 5 files changed, 117 insertions(+) diff --git a/src/gateway/session-utils.fs.test.ts b/src/gateway/session-utils.fs.test.ts index 3d04223d4a..6d4a628c40 100644 --- a/src/gateway/session-utils.fs.test.ts +++ b/src/gateway/session-utils.fs.test.ts @@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, test } from "vitest"; import { readFirstUserMessageFromTranscript, readLastMessagePreviewFromTranscript, + readSessionMessages, readSessionPreviewItemsFromTranscript, } from "./session-utils.fs.js"; @@ -343,6 +344,53 @@ describe("readLastMessagePreviewFromTranscript", () => { }); }); +describe("readSessionMessages", () => { + let tmpDir: string; + let storePath: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-session-fs-test-")); + storePath = path.join(tmpDir, "sessions.json"); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + test("includes synthetic compaction markers for compaction entries", () => { + const sessionId = "test-session-compaction"; + const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`); + const lines = [ + JSON.stringify({ type: "session", version: 1, id: sessionId }), + JSON.stringify({ message: { role: "user", content: "Hello" } }), + JSON.stringify({ + type: "compaction", + id: "comp-1", + timestamp: "2026-02-07T00:00:00.000Z", + summary: "Compacted history", + firstKeptEntryId: "x", + tokensBefore: 123, + }), + JSON.stringify({ message: { role: "assistant", content: "World" } }), + ]; + fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8"); + + const out = readSessionMessages(sessionId, storePath); + expect(out).toHaveLength(3); + const marker = out[1] as { + role: string; + content?: Array<{ text?: string }>; + __openclaw?: { kind?: string; id?: string }; + timestamp?: number; + }; + expect(marker.role).toBe("system"); + expect(marker.content?.[0]?.text).toBe("Compaction"); + expect(marker.__openclaw?.kind).toBe("compaction"); + expect(marker.__openclaw?.id).toBe("comp-1"); + expect(typeof marker.timestamp).toBe("number"); + }); +}); + describe("readSessionPreviewItemsFromTranscript", () => { let tmpDir: string; let storePath: string; diff --git a/src/gateway/session-utils.fs.ts b/src/gateway/session-utils.fs.ts index 421bae3f0d..70a5b7adfe 100644 --- a/src/gateway/session-utils.fs.ts +++ b/src/gateway/session-utils.fs.ts @@ -28,6 +28,23 @@ export function readSessionMessages( const parsed = JSON.parse(line); if (parsed?.message) { messages.push(parsed.message); + continue; + } + + // Compaction entries are not "message" records, but they're useful context for debugging. + // Emit a lightweight synthetic message that the Web UI can render as a divider. + if (parsed?.type === "compaction") { + const ts = typeof parsed.timestamp === "string" ? Date.parse(parsed.timestamp) : Number.NaN; + const timestamp = Number.isFinite(ts) ? ts : Date.now(); + messages.push({ + role: "system", + content: [{ type: "text", text: "Compaction" }], + timestamp, + __openclaw: { + kind: "compaction", + id: typeof parsed.id === "string" ? parsed.id : undefined, + }, + }); } } catch { // ignore bad lines diff --git a/ui/src/styles/chat/grouped.css b/ui/src/styles/chat/grouped.css index e91989dfc1..c43743267a 100644 --- a/ui/src/styles/chat/grouped.css +++ b/ui/src/styles/chat/grouped.css @@ -54,6 +54,33 @@ opacity: 0.7; } +/* Chat divider (e.g., compaction marker) */ +.chat-divider { + display: flex; + align-items: center; + gap: 10px; + margin: 18px 8px; + color: var(--muted); + font-size: 11px; + letter-spacing: 0.08em; + text-transform: uppercase; + user-select: none; +} + +.chat-divider__line { + flex: 1 1 0; + height: 1px; + background: var(--border); + opacity: 0.9; +} + +.chat-divider__label { + padding: 2px 10px; + border: 1px solid var(--border); + border-radius: 999px; + background: rgba(255, 255, 255, 0.02); +} + /* Avatar Styles */ .chat-avatar { width: 40px; diff --git a/ui/src/ui/types/chat-types.ts b/ui/src/ui/types/chat-types.ts index 0638f494d4..aba1b17301 100644 --- a/ui/src/ui/types/chat-types.ts +++ b/ui/src/ui/types/chat-types.ts @@ -5,6 +5,7 @@ /** Union type for items in the chat thread */ export type ChatItem = | { kind: "message"; key: string; message: unknown } + | { kind: "divider"; key: string; label: string; timestamp: number } | { kind: "stream"; key: string; text: string; startedAt: number } | { kind: "reading-indicator"; key: string }; diff --git a/ui/src/ui/views/chat.ts b/ui/src/ui/views/chat.ts index 8c36b59114..70ceeb9131 100644 --- a/ui/src/ui/views/chat.ts +++ b/ui/src/ui/views/chat.ts @@ -224,6 +224,16 @@ export function renderChat(props: ChatProps) { buildChatItems(props), (item) => item.key, (item) => { + if (item.kind === "divider") { + return html` +