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.
This commit is contained in:
Val Alexander
2026-05-02 23:29:28 -05:00
committed by GitHub
parent 85c000de1e
commit 2810f1219a
9 changed files with 160 additions and 10 deletions

View File

@@ -15,6 +15,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- 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.
- 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.
- 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.
- 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.
- 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.

View File

@@ -25,6 +25,7 @@ Status: the macOS/iOS SwiftUI chat UI talks directly to the Gateway WebSocket.
- The UI connects to the Gateway WebSocket and uses `chat.history`, `chat.send`, and `chat.inject`.
- `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]`.
- `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.
- 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.
- 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.
- 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.
- `chat.history` is also display-normalized: runtime-only OpenClaw context,

View File

@@ -112,17 +112,21 @@
/* Chat divider (e.g., compaction marker) */
.chat-divider {
display: flex;
align-items: center;
display: grid;
gap: 10px;
margin: 18px 8px;
color: var(--muted);
font-size: 11px;
letter-spacing: 0.08em;
text-transform: uppercase;
letter-spacing: 0;
user-select: none;
}
.chat-divider__rule {
display: flex;
align-items: center;
gap: 10px;
}
.chat-divider__line {
flex: 1 1 0;
height: 1px;
@@ -135,6 +139,29 @@
border: 1px solid var(--border);
border-radius: var(--radius-full);
background: rgba(255, 255, 255, 0.02);
font-weight: 600;
text-transform: uppercase;
}
.chat-divider__details {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 8px;
padding: 0 16px;
text-align: center;
}
.chat-divider__description {
max-width: min(620px, 100%);
color: var(--muted);
font-size: 12px;
line-height: 1.4;
}
.chat-divider__action {
white-space: nowrap;
}
/* Avatar Styles */

View File

@@ -2372,6 +2372,16 @@ export function renderApp(state: AppViewState) {
onAttachmentsChange: (next) => (state.chatAttachments = next),
onSend: () => state.handleSendChat(),
onCompact: () => state.handleSendChat("/compact", { restoreDraft: true }),
onOpenSessionCheckpoints: () => {
state.sessionsExpandedCheckpointKey = state.sessionKey;
state.setTab("sessions" as import("./navigation.ts").Tab);
void loadSessions(state, {
activeMinutes: 0,
limit: 0,
includeGlobal: true,
includeUnknown: true,
});
},
onToggleRealtimeTalk: () => state.toggleRealtimeTalk(),
canAbort: hasAbortableSessionRun(state),
onAbort: () => void state.handleAbortChat(),

View File

@@ -183,6 +183,35 @@ describe("buildChatItems", () => {
},
});
});
it("explains compaction boundaries and exposes the checkpoint action", () => {
const items = buildChatItems(
createProps({
messages: [
{
role: "system",
timestamp: 2_000,
__openclaw: {
kind: "compaction",
id: "checkpoint-1",
},
},
],
}),
);
expect(items).toHaveLength(1);
expect(items[0]).toMatchObject({
kind: "divider",
label: "Compacted history",
description:
"Earlier turns are preserved in a compaction checkpoint. Open session checkpoints to branch or restore that pre-compaction view.",
action: {
kind: "session-checkpoints",
label: "Open checkpoints",
},
});
});
});
function isCanvasBlock(block: unknown): boolean {

View File

@@ -223,7 +223,13 @@ export function buildChatItems(props: BuildChatItemsProps): Array<ChatItem | Mes
typeof marker.id === "string"
? `divider:compaction:${marker.id}`
: `divider:compaction:${normalized.timestamp}:${i}`,
label: "Compaction",
label: "Compacted history",
description:
"Earlier turns are preserved in a compaction checkpoint. Open session checkpoints to branch or restore that pre-compaction view.",
action: {
kind: "session-checkpoints",
label: "Open checkpoints",
},
timestamp: normalized.timestamp ?? Date.now(),
});
continue;

View File

@@ -5,7 +5,14 @@
/** 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: "divider";
key: string;
label: string;
description?: string;
action?: { kind: "session-checkpoints"; label: string };
timestamp: number;
}
| { kind: "stream"; key: string; text: string; startedAt: number }
| { kind: "reading-indicator"; key: string };

View File

@@ -53,6 +53,29 @@ vi.mock("../chat/build-chat-items.ts", () => ({
stream: string | null;
streamStartedAt: number | null;
}) => {
if (
props.messages.some(
(message) =>
typeof message === "object" &&
message !== null &&
(message as { __testDivider?: unknown }).__testDivider === true,
)
) {
return [
{
kind: "divider",
key: "divider:compaction:test",
label: "Compacted history",
description:
"Earlier turns are preserved in a compaction checkpoint. Open session checkpoints to branch or restore that pre-compaction view.",
action: {
kind: "session-checkpoints",
label: "Open checkpoints",
},
timestamp: 1,
},
];
}
if (props.messages.length > 0) {
return [
{
@@ -372,6 +395,7 @@ function renderChatView(overrides: Partial<Parameters<typeof renderChat>[0]> = {
onDismissSideResult: () => undefined,
onNewSession: () => undefined,
onClearHistory: () => undefined,
onOpenSessionCheckpoints: () => undefined,
agentsList: null,
currentAgentId: "main",
onAgentChange: () => undefined,
@@ -389,6 +413,25 @@ function renderChatView(overrides: Partial<Parameters<typeof renderChat>[0]> = {
return container;
}
describe("chat compaction divider", () => {
it("renders checkpoint recovery copy and action", () => {
const onOpenSessionCheckpoints = vi.fn();
const container = renderChatView({
messages: [{ __testDivider: true }],
onOpenSessionCheckpoints,
});
expect(container.textContent).toContain("Compacted history");
expect(container.textContent).toContain("Earlier turns are preserved");
const button = container.querySelector<HTMLButtonElement>(".chat-divider__action");
expect(button?.textContent).toContain("Open checkpoints");
button?.click();
expect(onOpenSessionCheckpoints).toHaveBeenCalledTimes(1);
});
});
afterEach(() => {
loadSessionsMock.mockClear();
refreshVisibleToolsEffectiveForCurrentSessionMock.mockClear();

View File

@@ -109,6 +109,7 @@ export type ChatProps = {
onHistoryKeydown?: (input: ChatInputHistoryKeyInput) => ChatInputHistoryKeyResult;
onSend: () => void;
onCompact?: () => void | Promise<void>;
onOpenSessionCheckpoints?: () => void | Promise<void>;
onToggleRealtimeTalk?: () => void;
onAbort?: () => void;
onQueueRemove: (id: string) => void;
@@ -906,10 +907,35 @@ export function renderChat(props: ChatProps) {
(item) => {
if (item.kind === "divider") {
return html`
<div class="chat-divider" role="separator" data-ts=${String(item.timestamp)}>
<span class="chat-divider__line"></span>
<span class="chat-divider__label">${item.label}</span>
<span class="chat-divider__line"></span>
<div class="chat-divider" data-ts=${String(item.timestamp)}>
<div class="chat-divider__rule" role="separator" aria-label=${item.label}>
<span class="chat-divider__line"></span>
<span class="chat-divider__label">${item.label}</span>
<span class="chat-divider__line"></span>
</div>
${item.description || item.action
? html`
<div class="chat-divider__details">
${item.description
? html`<span class="chat-divider__description">
${item.description}
</span>`
: nothing}
${item.action?.kind === "session-checkpoints" &&
props.onOpenSessionCheckpoints
? html`
<button
type="button"
class="btn btn--subtle btn--sm chat-divider__action"
@click=${() => props.onOpenSessionCheckpoints?.()}
>
${item.action.label}
</button>
`
: nothing}
</div>
`
: nothing}
</div>
`;
}