Skip to content

Commit 9f7f379

Browse files
committed
fix(ui): restore sidebar session picker interactivity above desktop workbench
The collapsed sidebar session picker was covered by the chat content area when the workspace rail was visible at wider viewports. Two issues caused this: 1. .sidebar-session-select--collapsed .chat-session-picker used var(--z-dropdown) which was never defined, creating an invalid z-index declaration (falls back to auto). 2. .shell-nav and .content--chat are grid siblings with equal z-index (auto), and .content--chat (later DOM) paints above .shell-nav, covering the session picker that extends from the nav column into the content column. Fix: add position:relative + z-index:10 to .shell-nav so it stacks above .content--chat; change overflow from hidden to visible so the session picker extends beyond the nav rail; replace undefined var(--z-dropdown) with z-index:100.
1 parent eb1b640 commit 9f7f379

2 files changed

Lines changed: 296 additions & 2 deletions

File tree

ui/src/styles/layout.css

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -460,9 +460,11 @@
460460

461461
.shell-nav {
462462
grid-area: nav;
463+
position: relative;
464+
z-index: 10;
463465
display: flex;
464466
min-height: 100%;
465-
overflow: hidden;
467+
overflow: visible;
466468
border-right: 1px solid color-mix(in srgb, var(--border) 74%, transparent);
467469
transition: width var(--shell-focus-duration) var(--shell-focus-ease);
468470
}
@@ -806,7 +808,7 @@
806808
left: calc(100% + 8px);
807809
width: min(360px, calc(100vw - 96px));
808810
max-width: min(360px, calc(100vw - 96px));
809-
z-index: var(--z-dropdown);
811+
z-index: 100;
810812
}
811813

812814
.sidebar-recent-session {
Lines changed: 292 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,292 @@
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">/Users/nianjiu/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+
});

0 commit comments

Comments
 (0)