Skip to content

Commit 09190e4

Browse files
committed
fix(ui): keep dashboard mounted during reconnect
1 parent 8ab36e4 commit 09190e4

10 files changed

Lines changed: 265 additions & 29 deletions

ui/src/ui/app-gateway.node.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,8 @@ function createHost(): TestGatewayHost {
166166
clientInstanceId: "instance-test",
167167
client: null,
168168
connected: false,
169+
hasConnectedGateway: false,
170+
gatewayDeviceTokenRetryPending: false,
169171
hello: null,
170172
lastError: null,
171173
lastErrorCode: null,
@@ -1156,6 +1158,7 @@ describe("connectGateway", () => {
11561158

11571159
client.emitHello();
11581160

1161+
expect(host.hasConnectedGateway).toBe(true);
11591162
await vi.waitFor(() => expect(loadControlUiBootstrapConfigMock).toHaveBeenCalledTimes(1));
11601163
expect(loadControlUiBootstrapConfigMock).toHaveBeenCalledWith(host, { applyIdentity: false });
11611164
});

ui/src/ui/app-gateway.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,8 @@ type GatewayHost = {
109109
clientInstanceId: string;
110110
client: GatewayBrowserClient | null;
111111
connected: boolean;
112+
hasConnectedGateway: boolean;
113+
gatewayDeviceTokenRetryPending: boolean;
112114
hello: GatewayHelloOk | null;
113115
lastError: string | null;
114116
lastErrorCode: string | null;
@@ -777,6 +779,7 @@ export function connectGateway(host: GatewayHost, options?: ConnectGatewayOption
777779
host.chatError = null;
778780
host.hello = null;
779781
host.connected = false;
782+
host.gatewayDeviceTokenRetryPending = false;
780783
if (reconnectReason === "seq-gap") {
781784
host.execApprovalQueue = pruneExecApprovalQueue(host.execApprovalQueue);
782785
clearPendingQueueItemsForRun(
@@ -808,6 +811,8 @@ export function connectGateway(host: GatewayHost, options?: ConnectGatewayOption
808811
}
809812
shutdownHost.pendingShutdownMessage = null;
810813
host.connected = true;
814+
host.hasConnectedGateway = true;
815+
host.gatewayDeviceTokenRetryPending = false;
811816
host.lastError = null;
812817
host.lastErrorCode = null;
813818
host.chatError = null;
@@ -906,11 +911,12 @@ export function connectGateway(host: GatewayHost, options?: ConnectGatewayOption
906911
void verifyPendingUpdateVersion(host, client);
907912
});
908913
},
909-
onClose: ({ code, reason, error }) => {
914+
onClose: ({ code, reason, error, deviceTokenRetryPending }) => {
910915
if (host.client !== client) {
911916
return;
912917
}
913918
host.connected = false;
919+
host.gatewayDeviceTokenRetryPending = deviceTokenRetryPending === true;
914920
markQueuedChatSendsWaitingForReconnect(
915921
host as unknown as Parameters<typeof markQueuedChatSendsWaitingForReconnect>[0],
916922
);

ui/src/ui/app-render.assistant-avatar.test.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import { html, render } from "lit";
44
import { beforeEach, describe, expect, it, vi } from "vitest";
5+
import { ConnectErrorDetailCodes } from "../../../packages/gateway-protocol/src/connect-error-details.js";
56
import { i18n } from "../i18n/index.ts";
67
import type { AppViewState } from "./app-view-state.ts";
78
import type { ChatProps } from "./views/chat.ts";
@@ -81,6 +82,8 @@ function createState(overrides: Partial<AppViewState> = {}): AppViewState {
8182
onboarding: false,
8283
basePath: "",
8384
connected: true,
85+
hasConnectedGateway: true,
86+
gatewayDeviceTokenRetryPending: false,
8487
theme: "claw",
8588
themeMode: "dark",
8689
themeResolved: "dark",
@@ -220,6 +223,7 @@ function createState(overrides: Partial<AppViewState> = {}): AppViewState {
220223
clearCustomTheme: vi.fn(),
221224
setBorderRadius: vi.fn(),
222225
setTextScale: vi.fn(),
226+
setGatewayPassword: vi.fn(),
223227
applySettings: vi.fn(),
224228
applyLocalUserIdentity: vi.fn(),
225229
loadOverview: vi.fn(),
@@ -450,6 +454,86 @@ describe("renderApp assistant avatar routing", () => {
450454
expect(chatProps.current?.error).toBe("gateway disconnected");
451455
});
452456

457+
it("keeps the dashboard mounted during transient reconnects after a successful connection", () => {
458+
const container = document.createElement("div");
459+
460+
render(
461+
renderApp(
462+
createState({
463+
tab: "chat",
464+
connected: false,
465+
hasConnectedGateway: true,
466+
lastError: "disconnected (1006): no reason",
467+
} as Partial<AppViewState>),
468+
),
469+
container,
470+
);
471+
472+
expect(container.querySelector(".shell")).toBeInstanceOf(HTMLElement);
473+
expect(container.querySelector(".login-gate")).toBeNull();
474+
expect(chatProps.current?.error).toBe("disconnected (1006): no reason");
475+
});
476+
477+
it("shows the login gate after a successful connection when auth is rejected", () => {
478+
const container = document.createElement("div");
479+
480+
render(
481+
renderApp(
482+
createState({
483+
connected: false,
484+
hasConnectedGateway: true,
485+
lastError: "gateway token mismatch",
486+
lastErrorCode: "AUTH_TOKEN_MISMATCH",
487+
} as Partial<AppViewState>),
488+
),
489+
container,
490+
);
491+
492+
expect(container.querySelector(".login-gate")).toBeInstanceOf(HTMLElement);
493+
expect(container.querySelector(".shell")).toBeNull();
494+
});
495+
496+
it("keeps the dashboard mounted during automatic device token retries", () => {
497+
const container = document.createElement("div");
498+
499+
render(
500+
renderApp(
501+
createState({
502+
tab: "chat",
503+
connected: false,
504+
hasConnectedGateway: true,
505+
gatewayDeviceTokenRetryPending: true,
506+
lastError: "gateway token mismatch",
507+
lastErrorCode: ConnectErrorDetailCodes.AUTH_TOKEN_MISMATCH,
508+
} as Partial<AppViewState>),
509+
),
510+
container,
511+
);
512+
513+
expect(container.querySelector(".shell")).toBeInstanceOf(HTMLElement);
514+
expect(container.querySelector(".login-gate")).toBeNull();
515+
expect(chatProps.current?.error).toBe("gateway token mismatch");
516+
});
517+
518+
it("shows the login gate after a successful connection when auth scope must change", () => {
519+
const container = document.createElement("div");
520+
521+
render(
522+
renderApp(
523+
createState({
524+
connected: false,
525+
hasConnectedGateway: true,
526+
lastError: "requested scopes are not approved",
527+
lastErrorCode: ConnectErrorDetailCodes.AUTH_SCOPE_MISMATCH,
528+
} as Partial<AppViewState>),
529+
),
530+
container,
531+
);
532+
533+
expect(container.querySelector(".login-gate")).toBeInstanceOf(HTMLElement);
534+
expect(container.querySelector(".shell")).toBeNull();
535+
});
536+
453537
it("does not rebuild chat composer controls for draft-only rerenders", () => {
454538
const container = document.createElement("div");
455539
const state = createState({ tab: "chat", chatMessage: "" });

ui/src/ui/app-render.ts

Lines changed: 51 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import { html, nothing } from "lit";
33
import { guard } from "lit/directives/guard.js";
44
import { styleMap } from "lit/directives/style-map.js";
5+
import { ConnectErrorDetailCodes } from "../../../packages/gateway-protocol/src/connect-error-details.js";
56
import { i18n, t } from "../i18n/index.ts";
67
import { getSafeLocalStorage } from "../local-storage.ts";
78
import {
@@ -158,6 +159,7 @@ import { getCronJobPayload } from "./cron-payload.ts";
158159
import { buildExternalLinkRel, EXTERNAL_LINK_TARGET } from "./external-link.ts";
159160
import { formatTimeMs } from "./format.ts";
160161
import { formatRelativeTimestamp } from "./format.ts";
162+
import { isNonRecoverableAuthErrorCode } from "./gateway.ts";
161163
import { icons } from "./icons.ts";
162164
import { createLazyView, renderLazyView } from "./lazy-view.ts";
163165
import {
@@ -217,6 +219,11 @@ import { renderExecApprovalPrompt } from "./views/exec-approval.ts";
217219
import { renderGatewayUrlConfirmation } from "./views/gateway-url-confirmation.ts";
218220
import { renderLoginGate } from "./views/login-gate.ts";
219221
import { renderMcp } from "./views/mcp.ts";
222+
import {
223+
resolveAuthHintKind,
224+
resolvePairingHint,
225+
shouldShowInsecureContextHint,
226+
} from "./views/overview-hints.ts";
220227
import { renderOverview } from "./views/overview.ts";
221228

222229
let pendingUpdate: (() => void) | undefined;
@@ -261,6 +268,46 @@ function setSkillWorkshopUseCurrentChatForRevisions(state: AppViewState, enabled
261268
}
262269
}
263270

271+
function shouldRenderLoginGate(state: AppViewState): boolean {
272+
if (state.connected) {
273+
return false;
274+
}
275+
if (!state.hasConnectedGateway) {
276+
return true;
277+
}
278+
if (!state.lastError) {
279+
return false;
280+
}
281+
if (state.gatewayDeviceTokenRetryPending) {
282+
return false;
283+
}
284+
285+
if (resolvePairingHint(false, state.lastError, state.lastErrorCode)) {
286+
return true;
287+
}
288+
if (
289+
resolveAuthHintKind({
290+
connected: false,
291+
lastError: state.lastError,
292+
lastErrorCode: state.lastErrorCode,
293+
hasToken: Boolean(state.settings.token?.trim()),
294+
hasPassword: Boolean(state.password?.trim()),
295+
})
296+
) {
297+
return true;
298+
}
299+
if (shouldShowInsecureContextHint(false, state.lastError, state.lastErrorCode)) {
300+
return true;
301+
}
302+
if (isNonRecoverableAuthErrorCode(state.lastErrorCode)) {
303+
return true;
304+
}
305+
if (state.lastErrorCode === ConnectErrorDetailCodes.CONTROL_UI_ORIGIN_NOT_ALLOWED) {
306+
return true;
307+
}
308+
return state.lastError.toLowerCase().includes("protocol mismatch");
309+
}
310+
264311
function setSkillWorkshopMode(state: AppViewState, mode: "board" | "today"): void {
265312
if (state.skillWorkshopMode === mode) {
266313
return;
@@ -1386,9 +1433,10 @@ export function renderApp(state: AppViewState) {
13861433
: undefined;
13871434
pendingUpdate = requestHostUpdate;
13881435

1389-
// Gate: require successful gateway connection before showing the dashboard.
1436+
// Gate only first-connect and explicit auth/config failures; transient reconnects
1437+
// keep the mounted dashboard visible so tab refocus does not flash credentials.
13901438
// The gateway URL confirmation overlay is always rendered so URL-param flows still work.
1391-
if (!state.connected) {
1439+
if (shouldRenderLoginGate(state)) {
13921440
return html` ${renderLoginGate(state)} ${renderGatewayUrlConfirmation(state)} `;
13931441
}
13941442

@@ -2761,7 +2809,7 @@ export function renderApp(state: AppViewState) {
27612809
showGatewayToken: state.overviewShowGatewayToken,
27622810
showGatewayPassword: state.overviewShowGatewayPassword,
27632811
onSettingsChange: (next) => state.applySettings(next),
2764-
onPasswordChange: (next) => (state.password = next),
2812+
onPasswordChange: (next) => state.setGatewayPassword(next),
27652813
onSessionKeyChange: (next) => {
27662814
switchChatSession(state, next);
27672815
},

ui/src/ui/app-view-state.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ import type { ChatAbortOptions, ChatSendOptions } from "./app-chat.ts";
44
import type { EventLogEntry } from "./app-events.ts";
55
import type { CompactionStatus, FallbackStatus } from "./app-tool-stream.ts";
66
import type { ChatInputHistoryKeyInput, ChatInputHistoryKeyResult } from "./chat/input-history.ts";
7-
import type { RealtimeTalkConversationEntry } from "./chat/realtime-talk-conversation.ts";
87
import type { RealtimeTalkCatalogProvider } from "./chat/realtime-talk-catalog.ts";
8+
import type { RealtimeTalkConversationEntry } from "./chat/realtime-talk-conversation.ts";
99
import type { RealtimeTalkStatus } from "./chat/realtime-talk.ts";
1010
import type { ChatRunUiStatus } from "./chat/run-lifecycle.ts";
1111
import type { ChatMessageCache } from "./chat/session-message-cache.ts";
@@ -66,6 +66,8 @@ export type AppViewState = {
6666
onboarding: boolean;
6767
basePath: string;
6868
connected: boolean;
69+
hasConnectedGateway: boolean;
70+
gatewayDeviceTokenRetryPending: boolean;
6971
theme: ThemeName;
7072
themeMode: ThemeMode;
7173
themeResolved: ResolvedTheme;
@@ -489,6 +491,7 @@ export type AppViewState = {
489491
clearCustomTheme: () => void;
490492
setBorderRadius: (value: number) => void;
491493
setTextScale: (value: number) => void;
494+
setGatewayPassword: (value: string) => void;
492495
applySettings: (next: UiSettings) => void;
493496
applyLocalUserIdentity?: (next: { name?: string | null; avatar?: string | null }) => void;
494497
loadOverview: (opts?: { refresh?: boolean }) => Promise<void>;

ui/src/ui/app.ts

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -77,16 +77,16 @@ import type { AppViewState } from "./app-view-state.ts";
7777
import { normalizeAssistantIdentity } from "./assistant-identity.ts";
7878
import { restoreChatComposerState } from "./chat/composer-persistence.ts";
7979
import { exportChatMarkdown } from "./chat/export.ts";
80+
import {
81+
reconcileRealtimeTalkCatalogSelection,
82+
type RealtimeTalkCatalogProvider,
83+
} from "./chat/realtime-talk-catalog.ts";
8084
import {
8185
createRealtimeTalkConversationState,
8286
updateRealtimeTalkConversation,
8387
type RealtimeTalkConversationEntry,
8488
type RealtimeTalkConversationState,
8589
} from "./chat/realtime-talk-conversation.ts";
86-
import {
87-
reconcileRealtimeTalkCatalogSelection,
88-
type RealtimeTalkCatalogProvider,
89-
} from "./chat/realtime-talk-catalog.ts";
9090
import {
9191
RealtimeTalkSession,
9292
type RealtimeTalkLaunchOptions,
@@ -223,6 +223,8 @@ export class OpenClawApp extends LitElement {
223223
@state() tab: Tab = "chat";
224224
@state() onboarding = resolveOnboardingMode();
225225
@state() connected = false;
226+
@state() hasConnectedGateway = false;
227+
@state() gatewayDeviceTokenRetryPending = false;
226228
@state() theme: ThemeName = this.settings.theme ?? "claw";
227229
@state() themeMode: ThemeMode = this.settings.themeMode ?? "system";
228230
@state() themeResolved: ResolvedTheme = "dark";
@@ -983,6 +985,9 @@ export class OpenClawApp extends LitElement {
983985
}
984986

985987
applySettings(next: UiSettings) {
988+
if (next.gatewayUrl !== this.settings.gatewayUrl || next.token !== this.settings.token) {
989+
this.hasConnectedGateway = false;
990+
}
986991
applySettingsInternal(this as unknown as Parameters<typeof applySettingsInternal>[0], next);
987992
}
988993

@@ -1123,6 +1128,13 @@ export class OpenClawApp extends LitElement {
11231128
this.requestUpdate();
11241129
}
11251130

1131+
setGatewayPassword(value: string) {
1132+
if (value !== this.password) {
1133+
this.hasConnectedGateway = false;
1134+
}
1135+
this.password = value;
1136+
}
1137+
11261138
announceSessionSwitch(sessionKey: string, label: string) {
11271139
const id = ++this.sessionSwitchNoticeSeq;
11281140
if (this.sessionSwitchNoticeTimer !== null) {
@@ -1431,7 +1443,7 @@ export class OpenClawApp extends LitElement {
14311443
const nextToken = this.pendingGatewayToken?.trim() || "";
14321444
this.pendingGatewayUrl = null;
14331445
this.pendingGatewayToken = null;
1434-
applySettingsInternal(this as unknown as Parameters<typeof applySettingsInternal>[0], {
1446+
this.applySettings({
14351447
...this.settings,
14361448
gatewayUrl: nextGatewayUrl,
14371449
token: nextToken,

0 commit comments

Comments
 (0)