diff --git a/CHANGELOG.md b/CHANGELOG.md index 8879cd83b97..1f4ca3d9d4d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/web/webchat.md b/docs/web/webchat.md index 0499f607e09..1df49c775f8 100644 --- a/docs/web/webchat.md +++ b/docs/web/webchat.md @@ -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, diff --git a/ui/src/styles/chat/grouped.css b/ui/src/styles/chat/grouped.css index 9a2b9b20fea..562098b3d95 100644 --- a/ui/src/styles/chat/grouped.css +++ b/ui/src/styles/chat/grouped.css @@ -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 */ diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index c73772b00e8..ec00df6e5b4 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -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(), diff --git a/ui/src/ui/chat/build-chat-items.test.ts b/ui/src/ui/chat/build-chat-items.test.ts index fecdb767ced..9d835d67243 100644 --- a/ui/src/ui/chat/build-chat-items.test.ts +++ b/ui/src/ui/chat/build-chat-items.test.ts @@ -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 { diff --git a/ui/src/ui/chat/build-chat-items.ts b/ui/src/ui/chat/build-chat-items.ts index 4e01f6a0faa..da25798d223 100644 --- a/ui/src/ui/chat/build-chat-items.ts +++ b/ui/src/ui/chat/build-chat-items.ts @@ -223,7 +223,13 @@ export function buildChatItems(props: BuildChatItemsProps): Array ({ 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[0]> = { onDismissSideResult: () => undefined, onNewSession: () => undefined, onClearHistory: () => undefined, + onOpenSessionCheckpoints: () => undefined, agentsList: null, currentAgentId: "main", onAgentChange: () => undefined, @@ -389,6 +413,25 @@ function renderChatView(overrides: Partial[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(".chat-divider__action"); + expect(button?.textContent).toContain("Open checkpoints"); + + button?.click(); + + expect(onOpenSessionCheckpoints).toHaveBeenCalledTimes(1); + }); +}); + afterEach(() => { loadSessionsMock.mockClear(); refreshVisibleToolsEffectiveForCurrentSessionMock.mockClear(); diff --git a/ui/src/ui/views/chat.ts b/ui/src/ui/views/chat.ts index 1553bc5b1b0..d492c37e5bc 100644 --- a/ui/src/ui/views/chat.ts +++ b/ui/src/ui/views/chat.ts @@ -109,6 +109,7 @@ export type ChatProps = { onHistoryKeydown?: (input: ChatInputHistoryKeyInput) => ChatInputHistoryKeyResult; onSend: () => void; onCompact?: () => void | Promise; + onOpenSessionCheckpoints?: () => void | Promise; onToggleRealtimeTalk?: () => void; onAbort?: () => void; onQueueRemove: (id: string) => void; @@ -906,10 +907,35 @@ export function renderChat(props: ChatProps) { (item) => { if (item.kind === "divider") { return html` -