fix(ui): surface child session loading failures (#128418)

This commit is contained in:
Peter Steinberger
2026-08-23 20:59:16 -07:00
committed by GitHub
parent 9f36cb6187
commit ee7fe7be28
7 changed files with 201 additions and 16 deletions
@@ -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}
+1
View File
@@ -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";
+12 -14
View File
@@ -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 () => {