mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-09 03:22:40 +00:00
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:
@@ -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);
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user