mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:30:42 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user