fix(web-ui): skip hidden subagent picker pages

* fix(web-ui): skip hidden subagent picker pages

* test(ui): cover hidden chat picker pages in browser

* fix(web-ui): skip hidden subagent picker pages

---------

Co-authored-by: openclaw-clownfish[bot] <280122609+openclaw-clownfish[bot]@users.noreply.github.com>
This commit is contained in:
Gio Della-Libera
2026-06-22 06:55:09 -07:00
committed by GitHub
parent d3781cc4b8
commit a2b8f67395
3 changed files with 333 additions and 16 deletions

View File

@@ -357,9 +357,9 @@ function appendChatSessionPickerResult(
async function loadChatSessionPickerPage(
state: AppViewState,
options: { query?: string; offset?: number; append?: boolean } = {},
) {
): Promise<SessionsListResult | null> {
if (!state.client || !state.connected) {
return;
return null;
}
const query = normalizeOptionalString(options.query ?? state.chatSessionPickerAppliedQuery) ?? "";
const requestId = beginChatSessionPickerSearchRequest(
@@ -371,7 +371,7 @@ async function loadChatSessionPickerPage(
}),
);
if (requestId === null) {
return;
return null;
}
state.chatSessionPickerLoading = true;
state.chatSessionPickerError = null;
@@ -385,17 +385,19 @@ async function loadChatSessionPickerPage(
),
);
if (!isCurrentChatSessionPickerSearchRequest(state, requestId)) {
return;
return null;
}
const previous = state.chatSessionPickerResult ?? state.sessionsResult;
state.chatSessionPickerResult =
options.append === true && previous ? appendChatSessionPickerResult(previous, page) : page;
state.chatSessionPickerAppliedQuery = query;
return state.chatSessionPickerResult;
} catch (err) {
if (!isCurrentChatSessionPickerSearchRequest(state, requestId)) {
return;
return null;
}
state.chatSessionPickerError = String(err);
return null;
} finally {
if (isCurrentChatSessionPickerSearchRequest(state, requestId)) {
finishChatSessionPickerSearchRequest(state, requestId);
@@ -463,16 +465,28 @@ function updateChatSessionPickerSearchQuery(state: AppViewState, nextQuery: stri
}
async function loadMoreChatSessionPickerResults(state: AppViewState) {
const result = state.chatSessionPickerResult;
const offset = resolveNextChatSessionOffset(result);
if (offset === null) {
return;
let result = state.chatSessionPickerResult;
let offset = resolveNextChatSessionOffset(result);
let visibleCount = resolveChatSessionPickerRows(state, result).length;
const seenOffsets = new Set<number>();
while (offset !== null && !seenOffsets.has(offset)) {
seenOffsets.add(offset);
const next = await loadChatSessionPickerPage(state, {
query: state.chatSessionPickerAppliedQuery,
offset,
append: true,
});
if (!next) {
return;
}
result = next;
const nextVisibleCount = resolveChatSessionPickerRows(state, result).length;
if (nextVisibleCount > visibleCount) {
return;
}
visibleCount = nextVisibleCount;
offset = resolveNextChatSessionOffset(result);
}
await loadChatSessionPickerPage(state, {
query: state.chatSessionPickerAppliedQuery,
offset,
append: true,
});
}
function resolveChatSessionRow(
@@ -601,9 +615,10 @@ function renderChatSessionPickerPopover(
state.chatSessionPickerQuery.trim() !== "" || state.chatSessionPickerAppliedQuery.trim() !== "";
const loadMoreOffset = resolveNextChatSessionOffset(result);
const shownCount = pickerRows.length;
const rawLoadedCount = result?.sessions.length ?? 0;
const totalCount = result?.totalCount;
const countLabel =
typeof totalCount === "number" && Number.isFinite(totalCount)
rawLoadedCount === shownCount && typeof totalCount === "number" && Number.isFinite(totalCount)
? `${shownCount} / ${totalCount}`
: String(shownCount);

View File

@@ -1,4 +1,6 @@
// Control UI tests cover chat picker pagination behavior.
import { mkdir } from "node:fs/promises";
import path from "node:path";
import { chromium, type Browser } from "playwright";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import {
@@ -19,7 +21,12 @@ const describeControlUiE2e = chromiumAvailable || !allowMissingChromium ? descri
let browser: Browser;
let server: ControlUiE2eServer;
function sessionRow(key: string, label: string, updatedAt: number) {
function sessionRow(
key: string,
label: string,
updatedAt: number,
options: { spawnedBy?: string } = {},
) {
return {
contextTokens: null,
displayName: label,
@@ -32,6 +39,7 @@ function sessionRow(key: string, label: string, updatedAt: number) {
status: "done",
totalTokens: 0,
updatedAt,
...(options.spawnedBy ? { spawnedBy: options.spawnedBy } : {}),
};
}
@@ -220,4 +228,115 @@ describeControlUiE2e("Control UI chat picker mocked Gateway E2E", () => {
await context.close();
}
});
it("skips hidden subagent-only pages when loading more chat sessions through the GUI", async () => {
const baseTime = Date.parse("2026-06-04T12:00:00.000Z");
const context = await browser.newContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
});
const page = await context.newPage();
const gateway = await installMockGateway(page, {
methodResponses: {
"sessions.list": {
cases: [
{
match: { offset: 4 },
response: sessionsListResponse(
[sessionRow("agent:main:work", "Main work", baseTime - 240_000)],
{ hasMore: false, nextOffset: null, offset: 4, totalCount: 177 },
),
},
{
match: { offset: 2 },
response: sessionsListResponse(
[
sessionRow(
"agent:main:spawn-child:second",
"Subagent second",
baseTime - 120_000,
{ spawnedBy: "agent:main:main" },
),
sessionRow("agent:main:spawn-child:third", "Subagent third", baseTime - 180_000, {
spawnedBy: "agent:main:main",
}),
],
{ hasMore: true, nextOffset: 4, offset: 2, totalCount: 177 },
),
},
{
match: {},
response: sessionsListResponse(
[
sessionRow("agent:main:main", "Main chat", baseTime - 60_000),
sessionRow("agent:main:spawn-child:first", "Subagent first", baseTime - 90_000, {
spawnedBy: "agent:main:main",
}),
],
{ hasMore: true, nextOffset: 2, totalCount: 177 },
),
},
],
},
},
sessionKey: "agent:main:main",
});
try {
await page.goto(`${server.baseUrl}chat`);
await page.getByRole("button", { name: "Chat session" }).click();
await page.getByRole("option", { name: /Main chat/u }).waitFor({ timeout: 10_000 });
await page.getByText("1", { exact: true }).waitFor({ timeout: 10_000 });
await expect
.poll(() => page.getByRole("option", { name: /Subagent first/u }).count())
.toBe(0);
await page.getByRole("button", { name: "Load more sessions" }).click();
const hiddenPageRequest = await waitForSessionsRequest(
gateway,
(params) => params.offset === 2,
);
expect(requestParams(hiddenPageRequest)).toMatchObject({
configuredAgentsOnly: true,
includeGlobal: true,
includeUnknown: true,
limit: 50,
offset: 2,
});
const visiblePageRequest = await waitForSessionsRequest(
gateway,
(params) => params.offset === 4,
);
expect(requestParams(visiblePageRequest)).toMatchObject({
configuredAgentsOnly: true,
includeGlobal: true,
includeUnknown: true,
limit: 50,
offset: 4,
});
await page.getByRole("option", { name: /Main work/u }).waitFor({ timeout: 10_000 });
await page.getByText("2", { exact: true }).waitFor({ timeout: 10_000 });
await expect
.poll(() => page.getByRole("option", { name: /Subagent second/u }).count())
.toBe(0);
await expect
.poll(() => page.getByRole("button", { name: "Load more sessions" }).count())
.toBe(0);
if (process.env.OPENCLAW_CAPTURE_UI_PROOF === "1") {
const artifactDir = path.join(process.cwd(), ".artifacts", "pr-89323-control-ui-proof");
await mkdir(artifactDir, { recursive: true });
await page.screenshot({
fullPage: true,
path: path.join(artifactDir, "chat-picker-hidden-page-skip.png"),
});
}
} finally {
await context.close();
}
});
});

View File

@@ -3209,6 +3209,189 @@ describe("chat session controls", () => {
});
});
it("skips hidden subagent pages when loading more chat picker sessions", async () => {
const { state, request } = createChatHeaderState();
state.sessionsIncludeGlobal = false;
state.sessionsIncludeUnknown = false;
state.sessionKey = "agent:main:main";
state.settings.sessionKey = state.sessionKey;
state.chatSessionPickerOpen = true;
state.chatSessionPickerSurface = "desktop";
state.chatSessionPickerResult = createSessionsResultFromRows(
[
{ key: "agent:main:main", kind: "direct", label: "Main chat", updatedAt: 6 },
{
key: "agent:main:spawn-child:first",
kind: "direct",
label: "Subagent first",
updatedAt: 5,
spawnedBy: "agent:main:main",
},
],
{
hasMore: true,
nextOffset: 2,
totalCount: 177,
},
);
request
.mockResolvedValueOnce(
createSessionsResultFromRows(
[
{
key: "agent:main:spawn-child:second",
kind: "direct",
label: "Subagent second",
updatedAt: 4,
spawnedBy: "agent:main:main",
},
{
key: "agent:main:spawn-child:third",
kind: "direct",
label: "Subagent third",
updatedAt: 3,
spawnedBy: "agent:main:main",
},
],
{ hasMore: true, nextOffset: 4, offset: 2, totalCount: 177 },
),
)
.mockResolvedValueOnce(
createSessionsResultFromRows(
[
{
key: "agent:main:work",
kind: "direct",
label: "Main work",
updatedAt: 2,
},
],
{ hasMore: false, nextOffset: null, offset: 4, totalCount: 177 },
),
);
const container = document.createElement("div");
render(renderChatSessionSelect(state), container);
expect(container.querySelector(".chat-session-picker__count")?.textContent).toBe("1");
container
.querySelector<HTMLButtonElement>('button[data-chat-session-load-more="true"]')!
.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
await vi.waitFor(() =>
expect(state.chatSessionPickerResult?.sessions.map((row) => row.key)).toEqual([
"agent:main:main",
"agent:main:spawn-child:first",
"agent:main:spawn-child:second",
"agent:main:spawn-child:third",
"agent:main:work",
]),
);
expect(request).toHaveBeenNthCalledWith(1, "sessions.list", {
agentId: "main",
configuredAgentsOnly: true,
includeGlobal: true,
includeUnknown: true,
limit: 50,
offset: 2,
});
expect(request).toHaveBeenNthCalledWith(2, "sessions.list", {
agentId: "main",
configuredAgentsOnly: true,
includeGlobal: true,
includeUnknown: true,
limit: 50,
offset: 4,
});
render(renderChatSessionSelect(state), container);
const labels = Array.from(
container.querySelectorAll<HTMLElement>(".chat-session-picker__option-label"),
).map((node) => node.textContent?.trim());
expect(labels).toEqual(["Main chat", "Main work"]);
expect(container.querySelector(".chat-session-picker__count")?.textContent).toBe("2");
expect(container.querySelector('button[data-chat-session-load-more="true"]')).toBeNull();
});
it("continues past many hidden chat picker pages until a visible session is loaded", async () => {
const { state } = createChatHeaderState();
state.sessionsIncludeGlobal = false;
state.sessionsIncludeUnknown = false;
state.sessionKey = "agent:main:main";
state.settings.sessionKey = state.sessionKey;
state.chatSessionPickerOpen = true;
state.chatSessionPickerSurface = "desktop";
state.chatSessionPickerResult = createSessionsResultFromRows(
[{ key: "agent:main:main", kind: "direct", label: "Main chat", updatedAt: 20 }],
{ hasMore: true, nextOffset: 1, totalCount: 20 },
);
const request = vi.fn((method: string, params: Record<string, unknown> = {}) => {
if (method !== "sessions.list") {
throw new Error(`Unexpected request: ${method}`);
}
const offset =
typeof params.offset === "number" && Number.isFinite(params.offset) ? params.offset : 0;
if (offset < 12) {
return Promise.resolve(
createSessionsResultFromRows(
[
{
key: `agent:main:spawn-child:${offset}`,
kind: "direct",
label: `Subagent ${offset}`,
updatedAt: 20 - offset,
spawnedBy: "agent:main:main",
},
],
{ hasMore: true, nextOffset: offset + 1, offset, totalCount: 20 },
),
);
}
return Promise.resolve(
createSessionsResultFromRows(
[{ key: "agent:main:work", kind: "direct", label: "Main work", updatedAt: 1 }],
{ hasMore: false, nextOffset: null, offset, totalCount: 20 },
),
);
});
state.client = { request } as unknown as GatewayBrowserClient;
const container = document.createElement("div");
render(renderChatSessionSelect(state), container);
container
.querySelector<HTMLButtonElement>('button[data-chat-session-load-more="true"]')!
.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
await vi.waitFor(() =>
expect(state.chatSessionPickerResult?.sessions.at(-1)?.key).toBe("agent:main:work"),
);
expect(request).toHaveBeenCalledTimes(12);
expect(request).toHaveBeenCalledWith("sessions.list", {
agentId: "main",
configuredAgentsOnly: true,
includeGlobal: true,
includeUnknown: true,
limit: 50,
offset: 11,
});
expect(request).toHaveBeenCalledWith("sessions.list", {
agentId: "main",
configuredAgentsOnly: true,
includeGlobal: true,
includeUnknown: true,
limit: 50,
offset: 12,
});
render(renderChatSessionSelect(state), container);
const labels = Array.from(
container.querySelectorAll<HTMLElement>(".chat-session-picker__option-label"),
).map((node) => node.textContent?.trim());
expect(labels).toEqual(["Main chat", "Main work"]);
expect(container.querySelector(".chat-session-picker__count")?.textContent).toBe("2");
expect(container.querySelector('button[data-chat-session-load-more="true"]')).toBeNull();
});
it("loads unsearched picker pages from a scoped first page", async () => {
const { state } = createChatHeaderState();
state.sessionsIncludeGlobal = false;