fix(ui): restore live updates when retrying chat history (#147865)

This commit is contained in:
Peter Steinberger 2026-09-13 21:52:10 -07:00 • committed by GitHub
parent 1ea4fb7940
commit 9b3997201d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 84 additions and 15 deletions

View file

@ -70,6 +70,9 @@ keeps its last data and refreshes automatically once the Gateway accepts work ag
failures remain visible inline with their message and are retried automatically when the Gateway
becomes available again. These refresh callouts have no manual **Retry** button.
If chat history times out, its **Retry** action reloads the saved conversation and restores
its live session subscription, including approval updates.
Once the Gateway confirms that a message is in the transcript, reconnecting retires its temporary browser copy even when the original message is outside the latest history page. Loading older history shows the saved message in its original position without adding a second copy.
Queued attachments use binary Blobs in the browser's IndexedDB; the outbox keeps only delivery

View file

@ -35,6 +35,8 @@ import {
resolveChatHistoryPagination,
type ChatHistoryResult,
} from "./chat-history-snapshot.ts";
import { chatHistoryRequests, getChatHistoryLoadState } from "./chat-history-state.ts";
import { syncSelectedSessionMessageSubscription } from "./chat-history-subscription.ts";
import { loadChatHistory } from "./chat-history.ts";
import { ChatPaneReplyNavigation } from "./chat-pane-reply-navigation.ts";
import {
@ -47,6 +49,7 @@ import {
} from "./chat-pane-shared.ts";
import { isTranscriptScrollKey } from "./chat-scroll-input.ts";
import type { ChatState } from "./chat-state-contract.ts";
import { refreshPageChat } from "./chat-state-refresh.ts";
import { resolveChatAgentId } from "./chat-state-route.ts";
import { persistChatComposerState } from "./composer-persistence.ts";
import {
@ -59,6 +62,7 @@ import {
saveChatSessionScrollPosition,
scheduleChatScroll,
} from "./scroll.ts";
import { maybeResetToolStream } from "./stream-reconciliation.ts";
export abstract class ChatPaneHistory extends ChatPaneReplyNavigation {
private activeCatalogContinuation: symbol | null = null;
@ -74,6 +78,26 @@ export abstract class ChatPaneHistory extends ChatPaneReplyNavigation {
// in-flight prefetch or the join path could never consume it.
private stagedOlderGeneration = 0;
protected readonly refreshHistory = () => {
const state = this.state;
if (!state) {
return;
}
const catalogKey = parseCatalogSessionKey(state.sessionKey);
if (catalogKey) {
void this.loadCatalogSession(catalogKey, false);
return;
}
maybeResetToolStream(state, { preserveStreamSegments: state.chatRunId !== null });
this.reconcileWaitingApprovalSnapshot();
if (chatHistoryRequests(state).subscriptionError) {
void syncSelectedSessionMessageSubscription(state);
}
const historyLoad = getChatHistoryLoadState(state);
const startup = historyLoad.phase === "failed" && historyLoad.startup;
void refreshPageChat(state, { awaitHistory: true, scheduleScroll: false, startup });
};
protected hasOlderMessages(): boolean {
const state = this.state;
if (!state) {

View file

@ -28,7 +28,7 @@ import {
import { showToast } from "../../lib/toast.ts";
import { mutateChatGoal, submitChatGoalDraft } from "./chat-goals.ts";
import { clearChatHistory } from "./chat-history-actions.ts";
import { getChatHistoryLoadState, isInitialChatHistoryUnavailable } from "./chat-history-state.ts";
import { isInitialChatHistoryUnavailable } from "./chat-history-state.ts";
import { resolveChatMessageAccess } from "./chat-message-access.ts";
import { chatModelUnavailableBanner, requiresChatModelSetup } from "./chat-model-setup.ts";
import { ChatPaneLayoutRender } from "./chat-pane-layout-render.ts";
@ -49,7 +49,7 @@ import { createChatQuestionActions } from "./chat-question-actions.ts";
import { dismissRealtimeTalkError } from "./chat-realtime.ts";
import { activeChatRunStartupStatus } from "./chat-run-startup.ts";
import { chatSendHoldReason } from "./chat-send-support.ts";
import { refreshChatCommands, refreshPageChat } from "./chat-state-refresh.ts";
import { refreshChatCommands } from "./chat-state-refresh.ts";
import {
resolveChatAgentId,
resolveChatAvatarUrl,
@ -66,7 +66,6 @@ import { resolveChatLinkFaviconFetcher } from "./link-favicon-loader.ts";
import { activeQueuedMessageEdit } from "./queued-message-edit.ts";
import { hasAbortableSessionRun, hasDirectSessionRun } from "./run-lifecycle.ts";
import { scheduleChatScroll } from "./scroll.ts";
import { maybeResetToolStream } from "./stream-reconciliation.ts";
import { resolveChatProjectionRunId } from "./tool-stream-status.ts";
import { workspaceResultConflictFromPlacement } from "./workspace-conflict.ts";
@ -545,17 +544,7 @@ export class ChatPane extends ChatPaneLayoutRender {
onOpenWorkspaceFile: (target) => openSessionWorkspaceFile(state, target),
onOpenSessionLink: (target) => navigateMarkdownSession(this.context, target),
onRevealWorkspaceFile: (path) => revealSessionWorkspaceFile(state, path),
onRefresh: () => {
if (catalogKey) {
void this.loadCatalogSession(catalogKey, false);
return;
}
maybeResetToolStream(state, { preserveStreamSegments: state.chatRunId !== null });
this.reconcileWaitingApprovalSnapshot();
const historyLoad = getChatHistoryLoadState(state);
const startup = historyLoad.phase === "failed" && historyLoad.startup;
void refreshPageChat(state, { awaitHistory: true, scheduleScroll: false, startup });
},
onRefresh: this.refreshHistory,
onChatScroll: (event) => this.handleTranscriptScroll(event),
onHistoryIntent: (event) => this.handleTranscriptHistoryIntent(event),
// Lazy SVG sizing can resize a committed row; re-enter the scroll owner

View file

@ -1,18 +1,71 @@
/* @vitest-environment jsdom */
import { GatewayProtocolRequestTimeoutError } from "@openclaw/gateway-client/browser";
import { describe, expect, it, vi } from "vitest";
import { createDeferred } from "../../../../test/helpers/promise.js";
import type { GatewayBrowserClient } from "../../api/gateway.ts";
import type { ApplicationContext } from "../../app/context.ts";
import { gatewayHelloForMethods } from "../../test-helpers/gateway-methods.ts";
import type { ChatHistoryResult } from "./chat-history-snapshot.ts";
import { syncSelectedSessionMessageSubscription } from "./chat-history-subscription.ts";
import { loadChatHistory } from "./chat-history.ts";
import { createRefreshChatPane, nativeHistoryMessage } from "./chat-pane-history.test-support.ts";
import {
createGatewayBrowserClientFixture,
createInitializationContext,
createRenderTestChatPane,
createSessionCapabilityFixture,
} from "./chat-pane.test-support.ts";
describe("chat pane transcript loading signal", () => {
describe("chat pane transcript loading", () => {
it("restores live observation when refreshing after a subscription timeout", async () => {
const sessionKey = "agent:main:subscription-recovery";
const messages = [nativeHistoryMessage(2, "The node completed the work.")];
let failSubscription = true;
let observing = false;
const subscriptions: unknown[] = [];
const client = createGatewayBrowserClientFixture({
request: async (method, params) => {
if (method === "sessions.messages.subscribe") {
subscriptions.push(params);
observing = true;
if (failSubscription) {
failSubscription = false;
throw new GatewayProtocolRequestTimeoutError({
method,
timeoutMs: 30_000,
requestSent: true,
});
}
return { subscribed: true, key: sessionKey };
}
if (method === "sessions.messages.unsubscribe") {
observing = false;
return { subscribed: false, key: sessionKey };
}
return { messages, completeSnapshot: true, sessionId: "subscription-recovery" };
},
});
const { pane, state } = createRefreshChatPane(client);
state.sessionKey = sessionKey;
state.hello = gatewayHelloForMethods([], ["operator.read", "operator.approvals"]);
await syncSelectedSessionMessageSubscription(state);
expect(state.chatError).toContain("sessions.messages.subscribe");
expect(observing).toBe(false);
pane.render();
pane.chatProps!.onRefresh();
await vi.waitFor(() => expect(state.chatMessages).toEqual(messages));
expect(observing).toBe(true);
expect(subscriptions).toEqual([
{ key: sessionKey, includeApprovals: true },
{ key: sessionKey, includeApprovals: true },
]);
expect(state.chatError).toBeNull();
});
it("reports each transcript loading edge from the load owner without a render", async () => {
const pane = createRenderTestChatPane();
const first = createDeferred<ChatHistoryResult>();