mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 20:05:46 -06:00
fix(ui): surface child session loading failures (#128418)
This commit is contained in:
committed by
GitHub
parent
9f36cb6187
commit
ee7fe7be28
@@ -8,6 +8,7 @@ import { openCatalogSessionInTerminal } from "../lib/sessions/catalog-terminal.t
|
||||
import type { SidebarSessionSection } from "../lib/sessions/grouping.ts";
|
||||
import type { SessionCatalogGroupsRenderer } from "./app-sidebar-session-catalog-render.ts";
|
||||
import {
|
||||
renderChildSessionLoadError,
|
||||
renderRecentSession,
|
||||
renderSessionTree,
|
||||
type SessionListHost,
|
||||
@@ -451,6 +452,7 @@ export function renderSessionList(params: {
|
||||
catalogRenderer: SessionCatalogGroupsRenderer | null;
|
||||
}) {
|
||||
const { host } = params;
|
||||
const hiddenMainSessionKey = host.mainSessionRow()?.key;
|
||||
return html`
|
||||
<section
|
||||
class="sidebar-sessions ${host.sessionOrganizer.sessionListRemovalDrop
|
||||
@@ -461,6 +463,7 @@ export function renderSessionList(params: {
|
||||
@drop=${(event: DragEvent) => host.handleSessionListDrop(event)}
|
||||
>
|
||||
${renderSessionListToolbar(host)}
|
||||
${hiddenMainSessionKey ? renderChildSessionLoadError(host, hiddenMainSessionKey) : nothing}
|
||||
${host.sessionData.sessionMutationError
|
||||
? html`
|
||||
<div
|
||||
|
||||
@@ -237,7 +237,7 @@ export class AppSidebarSessionNavigationElement extends AppSidebarBase {
|
||||
session.childSessionKeys.length > 0 &&
|
||||
this.isSessionChildrenExpanded(session) &&
|
||||
!this.sessionData.loadedChildSessionKeys.has(session.key) &&
|
||||
!this.sessionData.failedChildSessionKeys.has(session.key) &&
|
||||
!this.sessionData.childSessionErrorsByParent.has(session.key) &&
|
||||
!this.sessionData.loadingChildSessionKeys.has(session.key)
|
||||
) {
|
||||
void this.sessionData.loadChildSessions(session.key);
|
||||
@@ -248,7 +248,7 @@ export class AppSidebarSessionNavigationElement extends AppSidebarBase {
|
||||
mainRow &&
|
||||
(mainRow.childSessions?.length ?? 0) > 0 &&
|
||||
!this.sessionData.loadedChildSessionKeys.has(mainRow.key) &&
|
||||
!this.sessionData.failedChildSessionKeys.has(mainRow.key) &&
|
||||
!this.sessionData.childSessionErrorsByParent.has(mainRow.key) &&
|
||||
!this.sessionData.loadingChildSessionKeys.has(mainRow.key)
|
||||
) {
|
||||
void this.sessionData.loadChildSessions(mainRow.key);
|
||||
|
||||
@@ -56,10 +56,12 @@ export interface SessionListHost {
|
||||
readonly sessionData: Pick<
|
||||
SessionDataController,
|
||||
| "approvalBadgeSnapshot"
|
||||
| "childSessionErrorsByParent"
|
||||
| "loadMoreSessionCatalog"
|
||||
| "presenceInstanceId"
|
||||
| "presencePayload"
|
||||
| "refreshSessionCatalogs"
|
||||
| "retryChildSessions"
|
||||
| "sessionCatalogRefreshStatus"
|
||||
| "sessionMutationError"
|
||||
>;
|
||||
@@ -100,6 +102,7 @@ export interface SessionListHost {
|
||||
sessionKey: string,
|
||||
worktreeId: string,
|
||||
): SessionPullRequestIndicatorState;
|
||||
mainSessionRow(): { key: string } | null;
|
||||
isSessionChildrenExpanded(session: SidebarRecentSession): boolean;
|
||||
startSessionDrag(session: SidebarRecentSession): void;
|
||||
finishSessionDrag(): void;
|
||||
@@ -459,6 +462,28 @@ export function renderRecentSession(params: {
|
||||
return keyed(session.key, row);
|
||||
}
|
||||
|
||||
export function renderChildSessionLoadError(host: SessionListHost, parentKey: string) {
|
||||
const error = host.sessionData.childSessionErrorsByParent.get(parentKey);
|
||||
if (!error) {
|
||||
return nothing;
|
||||
}
|
||||
return html`<div
|
||||
class="sidebar-session-error callout danger"
|
||||
data-child-session-error=${parentKey}
|
||||
role="alert"
|
||||
>
|
||||
<span>${error}</span>
|
||||
<button
|
||||
class="sidebar-session-tree__show-more"
|
||||
type="button"
|
||||
data-retry-child-sessions=${parentKey}
|
||||
@click=${() => host.sessionData.retryChildSessions(parentKey)}
|
||||
>
|
||||
${t("common.retry")}
|
||||
</button>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
export function renderSessionTree(params: {
|
||||
host: SessionListHost;
|
||||
session: SidebarRecentSession;
|
||||
@@ -503,6 +528,7 @@ export function renderSessionTree(params: {
|
||||
${t("sessionsView.showMoreChildren", { count: String(hiddenChildCount) })}
|
||||
</button>`
|
||||
: nothing}
|
||||
${renderChildSessionLoadError(host, session.key)}
|
||||
${session.loadingChildren && session.children.length === 0
|
||||
? html`<span class="sidebar-session-tree__loading">${t("common.loading")}</span>`
|
||||
: nothing}
|
||||
|
||||
@@ -16,6 +16,7 @@ import "../test-helpers/app-sidebar-cases/catalog-ownership.ts";
|
||||
import "../test-helpers/app-sidebar-cases/catalog-terminal-owner.ts";
|
||||
import "../test-helpers/app-sidebar-cases/catalog-pages.ts";
|
||||
import "../test-helpers/app-sidebar-cases/categorized-child-sessions.ts";
|
||||
import "../test-helpers/app-sidebar-cases/child-session-errors.ts";
|
||||
import "../test-helpers/app-sidebar-cases/child-sessions-cap.ts";
|
||||
import "../test-helpers/app-sidebar-cases/child-sessions.ts";
|
||||
import "../test-helpers/app-sidebar-cases/group-mutations.ts";
|
||||
|
||||
@@ -65,7 +65,7 @@ export class SessionDataController implements ReactiveController, SessionCatalog
|
||||
sessionsLoading = false;
|
||||
childSessionRowsByParent: Readonly<Record<string, readonly GatewaySessionRow[]>> = {};
|
||||
loadedChildSessionKeys: ReadonlySet<string> = new Set();
|
||||
failedChildSessionKeys: ReadonlySet<string> = new Set();
|
||||
childSessionErrorsByParent: ReadonlyMap<string, string> = new Map();
|
||||
loadingChildSessionKeys: ReadonlySet<string> = new Set();
|
||||
activeSessionLineageRoot: GatewaySessionRow | null = null;
|
||||
activeSessionLineageSelectedRow: GatewaySessionRow | null = null;
|
||||
@@ -383,7 +383,7 @@ export class SessionDataController implements ReactiveController, SessionCatalog
|
||||
)
|
||||
: {};
|
||||
this.loadedChildSessionKeys = new Set();
|
||||
this.failedChildSessionKeys = new Set();
|
||||
this.childSessionErrorsByParent = new Map();
|
||||
this.loadingChildSessionKeys = new Set();
|
||||
if (options.preserveActiveLineage !== true) {
|
||||
this.activeSessionLineageRoot = null;
|
||||
@@ -560,7 +560,7 @@ export class SessionDataController implements ReactiveController, SessionCatalog
|
||||
if (
|
||||
!parentKey ||
|
||||
this.loadedChildSessionKeys.has(parentKey) ||
|
||||
this.failedChildSessionKeys.has(parentKey) ||
|
||||
this.childSessionErrorsByParent.has(parentKey) ||
|
||||
this.loadingChildSessionKeys.has(parentKey)
|
||||
) {
|
||||
return;
|
||||
@@ -586,13 +586,8 @@ export class SessionDataController implements ReactiveController, SessionCatalog
|
||||
}
|
||||
this.childSessionRowsByParent = { ...this.childSessionRowsByParent, [parentKey]: rows };
|
||||
this.loadedChildSessionKeys = new Set([...this.loadedChildSessionKeys, parentKey]);
|
||||
if (this.failedChildSessionKeys.has(parentKey)) {
|
||||
const failedKeys = new Set(this.failedChildSessionKeys);
|
||||
failedKeys.delete(parentKey);
|
||||
this.failedChildSessionKeys = failedKeys;
|
||||
}
|
||||
this.notify();
|
||||
} catch {
|
||||
} catch (error) {
|
||||
if (generation !== this.childSessionGeneration || sessions !== this.context?.sessions) {
|
||||
return;
|
||||
}
|
||||
@@ -602,7 +597,10 @@ export class SessionDataController implements ReactiveController, SessionCatalog
|
||||
...this.childSessionRowsByParent,
|
||||
[parentKey]: this.childSessionRowsByParent[parentKey] ?? [],
|
||||
};
|
||||
this.failedChildSessionKeys = new Set([...this.failedChildSessionKeys, parentKey]);
|
||||
this.childSessionErrorsByParent = new Map(this.childSessionErrorsByParent).set(
|
||||
parentKey,
|
||||
formatUiError(error),
|
||||
);
|
||||
this.notify();
|
||||
} finally {
|
||||
if (generation === this.childSessionGeneration && sessions === this.context?.sessions) {
|
||||
@@ -718,10 +716,10 @@ export class SessionDataController implements ReactiveController, SessionCatalog
|
||||
}
|
||||
|
||||
retryChildSessions(sessionKey: string): void {
|
||||
if (this.failedChildSessionKeys.has(sessionKey)) {
|
||||
const failedKeys = new Set(this.failedChildSessionKeys);
|
||||
failedKeys.delete(sessionKey);
|
||||
this.failedChildSessionKeys = failedKeys;
|
||||
if (this.childSessionErrorsByParent.has(sessionKey)) {
|
||||
const errors = new Map(this.childSessionErrorsByParent);
|
||||
errors.delete(sessionKey);
|
||||
this.childSessionErrorsByParent = errors;
|
||||
this.notify();
|
||||
}
|
||||
void this.loadChildSessions(sessionKey);
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { GatewayBrowserClient } from "../../api/gateway.ts";
|
||||
import type { SessionsListResult } from "../../api/types.ts";
|
||||
import { createGateway, createSessionsHarness, mountSidebar } from "../app-sidebar.ts";
|
||||
import { waitForFast } from "../wait-for.ts";
|
||||
import "../../components/app-sidebar.ts";
|
||||
|
||||
function sessionResult(sessions: SessionsListResult["sessions"]): SessionsListResult {
|
||||
return {
|
||||
ts: 2,
|
||||
path: "",
|
||||
count: sessions.length,
|
||||
defaults: { modelProvider: null, model: null, contextTokens: null },
|
||||
sessions,
|
||||
};
|
||||
}
|
||||
|
||||
function parentSession(key: string, childKey: string) {
|
||||
return { key, kind: "direct" as const, updatedAt: 1, childSessions: [childKey] };
|
||||
}
|
||||
|
||||
function recoveredChild(key: string, parentKey: string, label: string) {
|
||||
return { key, spawnedBy: parentKey, kind: "direct" as const, label, updatedAt: 2 };
|
||||
}
|
||||
|
||||
describe("AppSidebar child-session load errors", () => {
|
||||
it.each([
|
||||
{
|
||||
failure: "temporary list failure",
|
||||
visibleError: "temporary list failure",
|
||||
},
|
||||
{
|
||||
failure: "OPENAI_API_KEY=sk-1234567890abcdef",
|
||||
visibleError: "OPENAI_API_KEY=sk-123...cdef",
|
||||
},
|
||||
])(
|
||||
"surfaces $visibleError with an accessible retry action",
|
||||
async ({ failure, visibleError }) => {
|
||||
const parentKey = "agent:main:parent";
|
||||
const childKey = "agent:worker:child";
|
||||
const gateway = createGateway({} as GatewayBrowserClient);
|
||||
const harness = createSessionsHarness("main", [parentKey]);
|
||||
harness.list
|
||||
.mockRejectedValueOnce(new Error(failure))
|
||||
.mockResolvedValueOnce(
|
||||
sessionResult([recoveredChild(childKey, parentKey, "Recovered child")]),
|
||||
);
|
||||
const { sidebar } = await mountSidebar(gateway, harness.sessions);
|
||||
harness.publishList({ result: sessionResult([parentSession(parentKey, childKey)]) });
|
||||
await sidebar.updateComplete;
|
||||
sidebar.querySelector<HTMLButtonElement>("[data-child-session-toggle]")?.click();
|
||||
|
||||
await waitForFast(() => {
|
||||
const alert = sidebar.querySelector(`[data-child-session-error="${parentKey}"]`);
|
||||
expect(alert?.getAttribute("role")).toBe("alert");
|
||||
expect(alert?.textContent).toContain(visibleError);
|
||||
});
|
||||
if (failure !== visibleError) {
|
||||
expect(sidebar.textContent).not.toContain(failure);
|
||||
}
|
||||
|
||||
const retry = sidebar.querySelector<HTMLButtonElement>(
|
||||
`[data-retry-child-sessions="${parentKey}"]`,
|
||||
);
|
||||
expect(retry?.textContent).toContain("Retry");
|
||||
retry?.click();
|
||||
|
||||
await waitForFast(() => expect(sidebar.textContent).toContain("Recovered child"));
|
||||
expect(harness.list).toHaveBeenCalledTimes(2);
|
||||
expect(sidebar.querySelector("[data-child-session-error]")).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps failures and retry state scoped to their parent", async () => {
|
||||
const firstParent = "agent:main:first-parent";
|
||||
const secondParent = "agent:main:second-parent";
|
||||
const gateway = createGateway({} as GatewayBrowserClient);
|
||||
const harness = createSessionsHarness("main", [firstParent, secondParent]);
|
||||
let firstAttempts = 0;
|
||||
harness.list.mockImplementation(async (options) => {
|
||||
const parentKey = options?.spawnedBy;
|
||||
if (parentKey === secondParent || firstAttempts++ === 0) {
|
||||
throw new Error(`${parentKey} temporarily unavailable`);
|
||||
}
|
||||
return sessionResult([
|
||||
recoveredChild("agent:worker:first-child", firstParent, "Recovered first child"),
|
||||
]);
|
||||
});
|
||||
const { sidebar } = await mountSidebar(gateway, harness.sessions);
|
||||
harness.publishList({
|
||||
result: sessionResult(
|
||||
[firstParent, secondParent].map((parentKey) =>
|
||||
parentSession(parentKey, `${parentKey}:child`),
|
||||
),
|
||||
),
|
||||
});
|
||||
await sidebar.updateComplete;
|
||||
for (const parentKey of [firstParent, secondParent]) {
|
||||
sidebar
|
||||
.querySelector<HTMLButtonElement>(`[data-child-session-toggle="${parentKey}"]`)
|
||||
?.click();
|
||||
}
|
||||
await waitForFast(() =>
|
||||
expect(sidebar.querySelectorAll("[data-child-session-error]")).toHaveLength(2),
|
||||
);
|
||||
|
||||
sidebar
|
||||
.querySelector<HTMLButtonElement>(`[data-retry-child-sessions="${firstParent}"]`)
|
||||
?.click();
|
||||
|
||||
await waitForFast(() => expect(sidebar.textContent).toContain("Recovered first child"));
|
||||
expect(sidebar.querySelector(`[data-child-session-error="${firstParent}"]`)).toBeNull();
|
||||
expect(
|
||||
sidebar.querySelector(`[data-child-session-error="${secondParent}"]`)?.textContent,
|
||||
).toContain(secondParent);
|
||||
});
|
||||
|
||||
it.each(["main", "agent:main:main"])(
|
||||
"surfaces and retries child failures for the hidden %s main session",
|
||||
async (parentKey) => {
|
||||
const childKey = "agent:main:subagent:recovered";
|
||||
const gateway = createGateway({} as GatewayBrowserClient);
|
||||
const harness = createSessionsHarness("main", [parentKey]);
|
||||
harness.list
|
||||
.mockRejectedValueOnce(new Error("main session children temporarily unavailable"))
|
||||
.mockResolvedValueOnce(
|
||||
sessionResult([recoveredChild(childKey, parentKey, "Recovered main-session child")]),
|
||||
);
|
||||
const { sidebar } = await mountSidebar(gateway, harness.sessions);
|
||||
harness.publishList({ result: sessionResult([parentSession(parentKey, childKey)]) });
|
||||
|
||||
await waitForFast(() => expect(harness.list).toHaveBeenCalledOnce());
|
||||
expect(sidebar.querySelector(`[data-session-key="${parentKey}"]`)).toBeNull();
|
||||
await waitForFast(() => {
|
||||
const alert = sidebar.querySelector(`[data-child-session-error="${parentKey}"]`);
|
||||
expect(alert?.getAttribute("role")).toBe("alert");
|
||||
expect(alert?.textContent).toContain("main session children temporarily unavailable");
|
||||
});
|
||||
|
||||
sidebar
|
||||
.querySelector<HTMLButtonElement>(`[data-retry-child-sessions="${parentKey}"]`)
|
||||
?.click();
|
||||
|
||||
await waitForFast(() =>
|
||||
expect(sidebar.textContent).toContain("Recovered main-session child"),
|
||||
);
|
||||
expect(harness.list).toHaveBeenCalledTimes(2);
|
||||
expect(sidebar.querySelector("[data-child-session-error]")).toBeNull();
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -350,6 +350,11 @@ describe("AppSidebar agent chip", () => {
|
||||
|
||||
await waitForFast(() => expect(harness.list).toHaveBeenCalledTimes(2));
|
||||
expect(sidebar.querySelector(".sidebar-recent-session--child")).toBeNull();
|
||||
await waitForFast(() =>
|
||||
expect(
|
||||
sidebar.querySelector('[data-child-session-error="agent:main:parent"]')?.textContent,
|
||||
).toContain("child session list returned no result"),
|
||||
);
|
||||
|
||||
publishParent(11);
|
||||
await waitForFast(() => expect(harness.list).toHaveBeenCalledTimes(3));
|
||||
@@ -427,6 +432,7 @@ describe("AppSidebar agent chip", () => {
|
||||
expect(sidebar.querySelector('[data-session-key="agent:worker:child"]')?.textContent).toContain(
|
||||
"Replacement child",
|
||||
);
|
||||
expect(sidebar.querySelector("[data-child-session-error]")).toBeNull();
|
||||
});
|
||||
|
||||
it("nests the selected child under its parent and reveals the active path", async () => {
|
||||
|
||||
Reference in New Issue
Block a user