mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 01:29:56 +00:00
fix(ui): restore live updates when retrying chat history (#147865)
This commit is contained in:
parent
1ea4fb7940
commit
9b3997201d
4 changed files with 84 additions and 15 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue