





















1+// Control UI tests cover sidebar session picker layering and interaction.
2+import { existsSync } from "node:fs";
3+import { chromium, type Browser, type Page } from "playwright";
4+import { afterAll, beforeAll, describe, expect, it } from "vitest";
5+import { readStyleSheet } from "../../../../test/helpers/ui-style-fixtures.js";
6+7+const describeBrowserLayout = existsSync(chromium.executablePath()) ? describe : describe.skip;
8+9+let browser: Browser;
10+11+function readUiCss(): string {
12+const files = [
13+"ui/src/styles/base.css",
14+"ui/src/styles/layout.css",
15+"ui/src/styles/layout.mobile.css",
16+"ui/src/styles/components.css",
17+"ui/src/styles/chat/layout.css",
18+"ui/src/styles/chat/text.css",
19+"ui/src/styles/chat/grouped.css",
20+"ui/src/styles/chat/tool-cards.css",
21+"ui/src/styles/chat/sidebar.css",
22+];
23+return files.map((file) => readStyleSheet(file)).join("\n");
24+}
25+26+function iconSvg() {
27+return `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 5v14M5 12h14"></path></svg>`;
28+}
29+30+function sidebarSessionPickerHtml(opts: { workspaceRail?: boolean } = {}) {
31+const optionButtons = Array.from({ length: 18 }, (_, index) => {
32+const sessionKey = `dashboard-session-${index + 1}`;
33+const selected = index === 0;
34+return `
35+ <button
36+ class="chat-session-picker__option${selected ? " chat-session-picker__option--selected" : ""}"
37+ data-chat-session-picker-option="true"
38+ data-session-key="${sessionKey}"
39+ role="option"
40+ aria-selected="${selected ? "true" : "false"}"
41+ title="Session ${index + 1}"
42+ type="button"
43+ >
44+ <span class="chat-session-picker__option-main">
45+ <span class="chat-session-picker__option-label">Session ${index + 1}</span>
46+ <span class="chat-session-picker__option-meta">workspace · gpt-5.5 · 2026-06-13 20:${String(
47+ index,
48+ ).padStart(2, "0")}</span>
49+ </span>
50+ ${selected ? `<span class="chat-session-picker__option-check" aria-hidden="true">${iconSvg()}</span>` : ""}
51+ </button>
52+ `;
53+}).join("");
54+const workspaceRail = opts.workspaceRail
55+ ? `
56+ <aside class="chat-workspace-rail" aria-label="Workspace files">
57+ <div class="chat-workspace-rail__header">
58+ <div class="chat-workspace-rail__title">
59+ <span class="chat-workspace-rail__eyebrow">Workspace</span>
60+ <strong>Files</strong>
61+ </div>
62+ <button class="btn btn--ghost btn--sm chat-workspace-rail__refresh" type="button" aria-label="Refresh files">
63+ ${iconSvg()}
64+ </button>
65+ </div>
66+ <div class="chat-workspace-rail__path">/workspace/openclaw</div>
67+ <div class="chat-workspace-rail__list" role="list">
68+ <button class="chat-workspace-rail__file chat-workspace-rail__file--active" type="button" role="listitem">
69+ <span class="chat-workspace-rail__file-icon">${iconSvg()}</span>
70+ <span class="chat-workspace-rail__file-main">
71+ <span class="chat-workspace-rail__file-name">ui/src/ui/chat/session-controls.ts</span>
72+ <span class="chat-workspace-rail__file-meta">24 KB</span>
73+ </span>
74+ </button>
75+ <button class="chat-workspace-rail__file" type="button" role="listitem">
76+ <span class="chat-workspace-rail__file-icon">${iconSvg()}</span>
77+ <span class="chat-workspace-rail__file-main">
78+ <span class="chat-workspace-rail__file-name">ui/src/styles/layout.css</span>
79+ <span class="chat-workspace-rail__file-meta">31 KB</span>
80+ </span>
81+ </button>
82+ </div>
83+ </aside>
84+ `
85+ : "";
86+return `
87+ <div class="shell shell--chat shell--nav-collapsed" data-chat-sidebar-picker-fixture>
88+ <header class="topbar">
89+ <div class="topnav-shell">
90+ <div class="topnav-shell__actions">
91+ <button class="topbar-search" type="button">
92+ <span class="topbar-search__label">Search</span>
93+ <kbd class="topbar-search__kbd">K</kbd>
94+ </button>
95+ </div>
96+ </div>
97+ </header>
98+ <div class="shell-nav">
99+ <aside class="sidebar sidebar--collapsed">
100+ <div class="sidebar-shell">
101+ <div class="sidebar-shell__header">
102+ <button class="nav-collapse-toggle" type="button" aria-label="Expand navigation">
103+ <span class="nav-collapse-toggle__icon" aria-hidden="true">${iconSvg()}</span>
104+ </button>
105+ </div>
106+ <div class="sidebar-shell__body">
107+ <section class="sidebar-sessions">
108+ <button class="sidebar-new-session" type="button" aria-label="New session">
109+ <span class="sidebar-new-session__icon" aria-hidden="true">${iconSvg()}</span>
110+ </button>
111+ <div class="sidebar-session-select sidebar-session-select--collapsed">
112+ <div class="chat-controls__session-row chat-controls__session-row--session-switcher chat-controls__session-row--single-agent chat-controls__session-row--compact">
113+ <div class="chat-controls__session chat-controls__session-picker">
114+ <button
115+ class="chat-controls__session-trigger"
116+ data-chat-session-select="true"
117+ type="button"
118+ title="main"
119+ aria-label="Chat session"
120+ aria-haspopup="dialog"
121+ aria-expanded="true"
122+ aria-controls="chat-session-picker-sidebar"
123+ >
124+ <span class="chat-controls__session-trigger-compact-icon" aria-hidden="true">${iconSvg()}</span>
125+ <span class="chat-controls__session-trigger-label">main</span>
126+ <span class="chat-controls__session-trigger-icon" aria-hidden="true">${iconSvg()}</span>
127+ </button>
128+ <div id="chat-session-picker-sidebar" class="chat-session-picker" role="dialog" aria-label="Chat session">
129+ <div class="chat-session-picker__search-row">
130+ <label class="field chat-session-picker__search">
131+ <input
132+ data-chat-session-picker-search="true"
133+ type="search"
134+ placeholder="Search sessions"
135+ aria-label="Search sessions"
136+ />
137+ </label>
138+ <button class="btn btn--ghost btn--icon chat-session-picker__icon-button" type="button" aria-label="Search">
139+ ${iconSvg()}
140+ </button>
141+ </div>
142+ <div class="chat-session-picker__list" role="listbox">
143+ ${optionButtons}
144+ </div>
145+ <div class="chat-session-picker__footer">
146+ <span class="chat-session-picker__count">18 / 18</span>
147+ </div>
148+ </div>
149+ </div>
150+ </div>
151+ </div>
152+ </section>
153+ </div>
154+ </div>
155+ </aside>
156+ </div>
157+ <main class="content content--chat">
158+ <section class="card chat">
159+ <div class="chat-workbench">
160+ <div class="chat-split-container">
161+ <div class="chat-main">
162+ <div class="chat-thread" role="log">
163+ <div class="chat-thread-inner">
164+ <div class="chat-group assistant">
165+ <div class="chat-group-messages">
166+ <div class="chat-bubble">
167+ <div class="chat-text">
168+ <p>Keep the sidebar session picker interactive even when the desktop chat workbench is visible.</p>
169+ </div>
170+ </div>
171+ </div>
172+ </div>
173+ <div
174+ style="display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;margin:28px 0 0;padding:0 8px;"
175+ >
176+ <button class="btn" type="button">What can you do?</button>
177+ <button class="btn" type="button">Summarize recent sessions</button>
178+ <button class="btn" type="button">Help me configure a channel</button>
179+ <button class="btn" type="button">Check system health</button>
180+ </div>
181+ </div>
182+ </div>
183+ </div>
184+ </div>
185+ ${workspaceRail}
186+ </div>
187+ </section>
188+ </main>
189+ </div>
190+ <script>
191+ const searchInput = document.querySelector('[data-chat-session-picker-search="true"]');
192+ if (searchInput instanceof HTMLInputElement) {
193+ searchInput.addEventListener('input', () => {
194+ document.body.dataset.searchValue = searchInput.value;
195+ });
196+ }
197+ for (const option of document.querySelectorAll('[data-chat-session-picker-option="true"]')) {
198+ option.addEventListener('click', () => {
199+ document.body.dataset.clickedSession = option.getAttribute('data-session-key') ?? '';
200+ });
201+ }
202+ </script>
203+ `;
204+}
205+206+async function openSidebarSessionPickerFixture(
207+width: number,
208+height: number,
209+opts: { workspaceRail?: boolean } = {},
210+): Promise<Page> {
211+const page = await browser.newPage({ viewport: { width, height } });
212+await page.setContent(
213+`<!doctype html><html><head><style>${readUiCss()}</style></head><body>${sidebarSessionPickerHtml(opts)}</body></html>`,
214+);
215+return page;
216+}
217+218+async function expectNoHorizontalOverflow(page: Page) {
219+const metrics = await page.evaluate(() => ({
220+body: document.body.scrollWidth,
221+html: document.documentElement.scrollWidth,
222+viewport: window.innerWidth,
223+}));
224+expect(metrics.html).toBeLessThanOrEqual(metrics.viewport + 1);
225+expect(metrics.body).toBeLessThanOrEqual(metrics.viewport + 1);
226+}
227+228+beforeAll(async () => {
229+browser = await chromium.launch({ headless: true });
230+});
231+232+afterAll(async () => {
233+await browser.close();
234+});
235+236+describeBrowserLayout("sidebar session picker browser layout", () => {
237+it("keeps the collapsed sidebar session picker interactive above the desktop workbench when the workspace rail is visible", async () => {
238+const page = await openSidebarSessionPickerFixture(1366, 900, { workspaceRail: true });
239+try {
240+await expectNoHorizontalOverflow(page);
241+const input = page.locator('[data-chat-session-picker-search="true"]');
242+const list = page.locator(".chat-session-picker__list");
243+const targetOption = page.locator(
244+'[data-chat-session-picker-option="true"][data-session-key="dashboard-session-12"]',
245+);
246+247+const inputHit = await input.evaluate((node) => {
248+const rect = (node as HTMLElement).getBoundingClientRect();
249+const hit = document.elementFromPoint(
250+rect.left + rect.width / 2,
251+rect.top + rect.height / 2,
252+);
253+return hit === node || node.contains(hit);
254+});
255+expect(inputHit).toBe(true);
256+257+await input.click();
258+await input.fill("telegram");
259+await expect
260+.poll(() => page.evaluate(() => document.body.dataset.searchValue ?? ""))
261+.toBe("telegram");
262+263+const listBox = await list.boundingBox();
264+if (!listBox) {
265+throw new Error("Expected session picker list bounding box");
266+}
267+const listScrollBefore = await list.evaluate((node) => (node as HTMLElement).scrollTop);
268+await page.mouse.move(listBox.x + listBox.width / 2, listBox.y + listBox.height / 2);
269+await page.mouse.wheel(0, 420);
270+await expect
271+.poll(() => list.evaluate((node) => (node as HTMLElement).scrollTop))
272+.toBeGreaterThan(listScrollBefore);
273+274+const optionHit = await targetOption.evaluate((node) => {
275+const rect = (node as HTMLElement).getBoundingClientRect();
276+const hit = document.elementFromPoint(
277+rect.left + rect.width / 2,
278+rect.top + Math.min(rect.height / 2, rect.height - 4),
279+);
280+return hit === node || node.contains(hit);
281+});
282+expect(optionHit).toBe(true);
283+284+await targetOption.click();
285+await expect
286+.poll(() => page.evaluate(() => document.body.dataset.clickedSession ?? ""))
287+.toBe("dashboard-session-12");
288+} finally {
289+await page.close();
290+}
291+});
292+});
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。