Skip to content

Commit 2810f12

Browse files
authored
fix(ui): surface compaction checkpoints in chat history
Fixes #76415. - Explains compacted history boundaries in WebChat. - Adds an Open checkpoints action for pre-compaction recovery. - Updates WebChat docs and changelog with Thanks @BunsDev. - Validated targeted UI tests, formatting/diff checks, Testbox changed gate, and exact-head CI. Security: UI/docs/tests/styles-only change that reuses existing checkpoint APIs; no new dependencies, filesystem reads, workflow changes, or secret handling.
1 parent 85c000d commit 2810f12

9 files changed

Lines changed: 160 additions & 10 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ Docs: https://docs.openclaw.ai
1515
### Fixes
1616

1717
- Docker/Gateway: pass Docker setup `.env` values into gateway and CLI containers and preserve exec SecretRef `passEnv` keys in managed service plans, so 1Password Connect-backed Discord tokens keep resolving after doctor or plugin repair. Thanks @vincentkoc.
18+
- Control UI/WebChat: explain compaction boundaries in chat history and link directly to session checkpoint controls so pre-compaction turns no longer look silently lost after refresh. Fixes #76415. Thanks @BunsDev.
1819
- Gateway/sessions: keep async `sessions.list` title and preview hydration bounded to transcript head/tail reads so Control UI polling cannot full-scan large session transcripts every refresh. Thanks @vincentkoc.
1920
- CLI/plugins: reject missing plugin ids before config writes in `plugins enable` and `plugins disable` so a typo no longer persists a stale config entry. (#73554) Thanks @ai-hpc.
2021
- Agents/sessions: preserve delivered trailing assistant replies during session-file repair so Telegram/WebChat history is not rewritten to drop already-delivered responses. Fixes #76329. Thanks @obviyus.

docs/web/webchat.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ Status: the macOS/iOS SwiftUI chat UI talks directly to the Gateway WebSocket.
2525
- The UI connects to the Gateway WebSocket and uses `chat.history`, `chat.send`, and `chat.inject`.
2626
- `chat.history` is bounded for stability: Gateway may truncate long text fields, omit heavy metadata, and replace oversized entries with `[chat.history omitted: message too large]`.
2727
- `chat.history` follows the active transcript branch for modern append-only session files, so abandoned rewrite branches and superseded prompt copies are not rendered in WebChat.
28+
- Compaction entries render as an explicit compacted-history divider. The divider explains that earlier turns are preserved in a checkpoint and links to the Sessions checkpoint controls, where operators can branch or restore the pre-compaction view when their permissions allow it.
2829
- Control UI remembers the backing Gateway `sessionId` returned by `chat.history` and includes it on follow-up `chat.send` calls, so reconnects and page refreshes continue the same stored conversation unless the user starts or resets a session.
2930
- Control UI coalesces duplicate in-flight submits for the same session, message, and attachments before generating a new `chat.send` run id; the Gateway still dedupes repeated requests that reuse the same idempotency key.
3031
- `chat.history` is also display-normalized: runtime-only OpenClaw context,

ui/src/styles/chat/grouped.css

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -112,17 +112,21 @@
112112

113113
/* Chat divider (e.g., compaction marker) */
114114
.chat-divider {
115-
display: flex;
116-
align-items: center;
115+
display: grid;
117116
gap: 10px;
118117
margin: 18px 8px;
119118
color: var(--muted);
120119
font-size: 11px;
121-
letter-spacing: 0.08em;
122-
text-transform: uppercase;
120+
letter-spacing: 0;
123121
user-select: none;
124122
}
125123

124+
.chat-divider__rule {
125+
display: flex;
126+
align-items: center;
127+
gap: 10px;
128+
}
129+
126130
.chat-divider__line {
127131
flex: 1 1 0;
128132
height: 1px;
@@ -135,6 +139,29 @@
135139
border: 1px solid var(--border);
136140
border-radius: var(--radius-full);
137141
background: rgba(255, 255, 255, 0.02);
142+
font-weight: 600;
143+
text-transform: uppercase;
144+
}
145+
146+
.chat-divider__details {
147+
display: flex;
148+
flex-wrap: wrap;
149+
align-items: center;
150+
justify-content: center;
151+
gap: 8px;
152+
padding: 0 16px;
153+
text-align: center;
154+
}
155+
156+
.chat-divider__description {
157+
max-width: min(620px, 100%);
158+
color: var(--muted);
159+
font-size: 12px;
160+
line-height: 1.4;
161+
}
162+
163+
.chat-divider__action {
164+
white-space: nowrap;
138165
}
139166

140167
/* Avatar Styles */

ui/src/ui/app-render.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2372,6 +2372,16 @@ export function renderApp(state: AppViewState) {
23722372
onAttachmentsChange: (next) => (state.chatAttachments = next),
23732373
onSend: () => state.handleSendChat(),
23742374
onCompact: () => state.handleSendChat("/compact", { restoreDraft: true }),
2375+
onOpenSessionCheckpoints: () => {
2376+
state.sessionsExpandedCheckpointKey = state.sessionKey;
2377+
state.setTab("sessions" as import("./navigation.ts").Tab);
2378+
void loadSessions(state, {
2379+
activeMinutes: 0,
2380+
limit: 0,
2381+
includeGlobal: true,
2382+
includeUnknown: true,
2383+
});
2384+
},
23752385
onToggleRealtimeTalk: () => state.toggleRealtimeTalk(),
23762386
canAbort: hasAbortableSessionRun(state),
23772387
onAbort: () => void state.handleAbortChat(),

ui/src/ui/chat/build-chat-items.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,35 @@ describe("buildChatItems", () => {
183183
},
184184
});
185185
});
186+
187+
it("explains compaction boundaries and exposes the checkpoint action", () => {
188+
const items = buildChatItems(
189+
createProps({
190+
messages: [
191+
{
192+
role: "system",
193+
timestamp: 2_000,
194+
__openclaw: {
195+
kind: "compaction",
196+
id: "checkpoint-1",
197+
},
198+
},
199+
],
200+
}),
201+
);
202+
203+
expect(items).toHaveLength(1);
204+
expect(items[0]).toMatchObject({
205+
kind: "divider",
206+
label: "Compacted history",
207+
description:
208+
"Earlier turns are preserved in a compaction checkpoint. Open session checkpoints to branch or restore that pre-compaction view.",
209+
action: {
210+
kind: "session-checkpoints",
211+
label: "Open checkpoints",
212+
},
213+
});
214+
});
186215
});
187216

188217
function isCanvasBlock(block: unknown): boolean {

ui/src/ui/chat/build-chat-items.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -223,7 +223,13 @@ export function buildChatItems(props: BuildChatItemsProps): Array<ChatItem | Mes
223223
typeof marker.id === "string"
224224
? `divider:compaction:${marker.id}`
225225
: `divider:compaction:${normalized.timestamp}:${i}`,
226-
label: "Compaction",
226+
label: "Compacted history",
227+
description:
228+
"Earlier turns are preserved in a compaction checkpoint. Open session checkpoints to branch or restore that pre-compaction view.",
229+
action: {
230+
kind: "session-checkpoints",
231+
label: "Open checkpoints",
232+
},
227233
timestamp: normalized.timestamp ?? Date.now(),
228234
});
229235
continue;

ui/src/ui/types/chat-types.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,14 @@
55
/** Union type for items in the chat thread */
66
export type ChatItem =
77
| { kind: "message"; key: string; message: unknown }
8-
| { kind: "divider"; key: string; label: string; timestamp: number }
8+
| {
9+
kind: "divider";
10+
key: string;
11+
label: string;
12+
description?: string;
13+
action?: { kind: "session-checkpoints"; label: string };
14+
timestamp: number;
15+
}
916
| { kind: "stream"; key: string; text: string; startedAt: number }
1017
| { kind: "reading-indicator"; key: string };
1118

ui/src/ui/views/chat.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,29 @@ vi.mock("../chat/build-chat-items.ts", () => ({
5353
stream: string | null;
5454
streamStartedAt: number | null;
5555
}) => {
56+
if (
57+
props.messages.some(
58+
(message) =>
59+
typeof message === "object" &&
60+
message !== null &&
61+
(message as { __testDivider?: unknown }).__testDivider === true,
62+
)
63+
) {
64+
return [
65+
{
66+
kind: "divider",
67+
key: "divider:compaction:test",
68+
label: "Compacted history",
69+
description:
70+
"Earlier turns are preserved in a compaction checkpoint. Open session checkpoints to branch or restore that pre-compaction view.",
71+
action: {
72+
kind: "session-checkpoints",
73+
label: "Open checkpoints",
74+
},
75+
timestamp: 1,
76+
},
77+
];
78+
}
5679
if (props.messages.length > 0) {
5780
return [
5881
{
@@ -372,6 +395,7 @@ function renderChatView(overrides: Partial<Parameters<typeof renderChat>[0]> = {
372395
onDismissSideResult: () => undefined,
373396
onNewSession: () => undefined,
374397
onClearHistory: () => undefined,
398+
onOpenSessionCheckpoints: () => undefined,
375399
agentsList: null,
376400
currentAgentId: "main",
377401
onAgentChange: () => undefined,
@@ -389,6 +413,25 @@ function renderChatView(overrides: Partial<Parameters<typeof renderChat>[0]> = {
389413
return container;
390414
}
391415

416+
describe("chat compaction divider", () => {
417+
it("renders checkpoint recovery copy and action", () => {
418+
const onOpenSessionCheckpoints = vi.fn();
419+
const container = renderChatView({
420+
messages: [{ __testDivider: true }],
421+
onOpenSessionCheckpoints,
422+
});
423+
424+
expect(container.textContent).toContain("Compacted history");
425+
expect(container.textContent).toContain("Earlier turns are preserved");
426+
const button = container.querySelector<HTMLButtonElement>(".chat-divider__action");
427+
expect(button?.textContent).toContain("Open checkpoints");
428+
429+
button?.click();
430+
431+
expect(onOpenSessionCheckpoints).toHaveBeenCalledTimes(1);
432+
});
433+
});
434+
392435
afterEach(() => {
393436
loadSessionsMock.mockClear();
394437
refreshVisibleToolsEffectiveForCurrentSessionMock.mockClear();

ui/src/ui/views/chat.ts

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ export type ChatProps = {
109109
onHistoryKeydown?: (input: ChatInputHistoryKeyInput) => ChatInputHistoryKeyResult;
110110
onSend: () => void;
111111
onCompact?: () => void | Promise<void>;
112+
onOpenSessionCheckpoints?: () => void | Promise<void>;
112113
onToggleRealtimeTalk?: () => void;
113114
onAbort?: () => void;
114115
onQueueRemove: (id: string) => void;
@@ -906,10 +907,35 @@ export function renderChat(props: ChatProps) {
906907
(item) => {
907908
if (item.kind === "divider") {
908909
return html`
909-
<div class="chat-divider" role="separator" data-ts=${String(item.timestamp)}>
910-
<span class="chat-divider__line"></span>
911-
<span class="chat-divider__label">${item.label}</span>
912-
<span class="chat-divider__line"></span>
910+
<div class="chat-divider" data-ts=${String(item.timestamp)}>
911+
<div class="chat-divider__rule" role="separator" aria-label=${item.label}>
912+
<span class="chat-divider__line"></span>
913+
<span class="chat-divider__label">${item.label}</span>
914+
<span class="chat-divider__line"></span>
915+
</div>
916+
${item.description || item.action
917+
? html`
918+
<div class="chat-divider__details">
919+
${item.description
920+
? html`<span class="chat-divider__description">
921+
${item.description}
922+
</span>`
923+
: nothing}
924+
${item.action?.kind === "session-checkpoints" &&
925+
props.onOpenSessionCheckpoints
926+
? html`
927+
<button
928+
type="button"
929+
class="btn btn--subtle btn--sm chat-divider__action"
930+
@click=${() => props.onOpenSessionCheckpoints?.()}
931+
>
932+
${item.action.label}
933+
</button>
934+
`
935+
: nothing}
936+
</div>
937+
`
938+
: nothing}
913939
</div>
914940
`;
915941
}

0 commit comments

Comments
 (0)