Compare commits

...

2 Commits

Author SHA1 Message Date
SabhyaC26 e2664d8e70 test(e2e_ui): cover the Files rail "Working folder" header toggle
Adds a Playwright test that drives the inline desktop Workspace rail and
asserts the working-folder header is a real button: it carries
aria-expanded, collapsing it hides the file-scope content and flips the
attribute to "false", and re-clicking restores it. This is the
browser-level guard for the frameless-vs-drawer header split (the unit
tests pin the render contract; this pins the live interaction the CI
e2e_ui gate requires for ap-web behavior changes). LLM-free.
2026-06-24 05:41:46 +00:00
SabhyaC26 67b3fd9d93 fix(ap-web): keep the Files rail "Working folder" header a button
The desktop Workspace rail renders <FilesPanel frameless />, and
`frameless` was folded into the `fullScreen` flag. That flag does two
unrelated jobs: (1) fill the parent height / drop the card chrome, and
(2) swap the collapsible "Working folder" *button* header for a static
<span> label (the drawer's header, which carries its own X close
button). Coupling them meant the inline rail lost the button header
entirely, rendering "Working folder" as a non-interactive label — so the
e2e UI suite, which targets the rail header by `role=button`
name="Working folder", timed out waiting for an element that no longer
existed (consistently red across PRs).

Split the flag into `isDrawer` (static label + close button, drawer only)
and `fillHeight` (rail + drawer). The inline rail and the standalone card
now both keep the collapsible button header (accessible name +
aria-expanded); only the drawer uses the static label. Drawer and card
behavior are unchanged.

Adds vitest coverage pinning the header role in card, frameless, and
drawer modes.
2026-06-24 05:41:46 +00:00
3 changed files with 139 additions and 11 deletions
+56
View File
@@ -203,6 +203,62 @@ describe("FilesPanel working folder directory", () => {
});
});
describe("FilesPanel working folder header role", () => {
// The inline right-rail panel passes `frameless` to fill the rail height
// without the card chrome. That must NOT downgrade the "Working folder"
// header to a plain label: it stays a collapsible button (accessible name
// + aria-expanded) so the rail header is focusable and toggleable, and so
// the e2e suite can target it by role. Only the drawer (onClose), which
// has its own X close button, uses the static label header.
it("renders the header as a collapsible button in the standalone card", () => {
renderPanel({ conversationId: "conv_header_card", files: [] });
const header = screen.getByRole("button", { name: /working folder/i });
expect(header).toHaveAttribute("aria-expanded", "true");
});
it("renders the header as a collapsible button in frameless (inline rail) mode", () => {
useAllFilesMock.mockReturnValue(allFilesResult([]));
useChangedFilesMock.mockReturnValue(changedFilesResult([]));
useDirectoryMock.mockReturnValue(directoryResult());
useEnvironmentMock.mockReturnValue(environmentResult("/home/user/workspace"));
useSearchMock.mockReturnValue(searchResult());
render(
<MemoryRouter initialEntries={["/c/conv_header_frameless"]}>
<Routes>
<Route
path="/c/:conversationId"
element={
<FilesPanel
sort="recent"
onSortChange={vi.fn()}
frameless
flatView={false}
onFileSelect={vi.fn()}
onFlatViewChange={vi.fn()}
showHidden={false}
onShowHiddenChange={vi.fn()}
/>
}
/>
</Routes>
</MemoryRouter>,
);
const header = screen.getByRole("button", { name: /working folder/i });
expect(header).toHaveAttribute("aria-expanded", "true");
});
it("renders a static label header (no toggle button) in the drawer", () => {
renderPanel({ conversationId: "conv_header_drawer", files: [], onClose: vi.fn() });
// The drawer has its own X close button, so the title is a plain label,
// not a collapse toggle.
expect(screen.queryByRole("button", { name: /working folder/i })).toBeNull();
expect(screen.getByText("Working folder")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Close files" })).toBeInTheDocument();
});
});
describe("FilesPanel scope switch (Changed | All) visibility", () => {
it("does not enable the root filesystem listing while showing Changed files", () => {
renderPanel({
+19 -11
View File
@@ -61,10 +61,11 @@ interface FilesPanelProps {
onClose?: () => void;
/**
* Frameless mode: drops the rounded card chrome and fills the parent
* container's height, just like `onClose` (fullScreen) mode — but
* without rendering a close button. Used by the inline right panel
* where the panel is embedded in a split layout rather than a drawer.
* The collapse chevron is also hidden in this mode.
* container's height (like the `onClose` drawer) — but without a close
* button. Used by the inline right panel where the panel is embedded in a
* split layout rather than a drawer. Unlike the drawer, it keeps the
* collapsible "Working folder" button header (with its chevron and
* `aria-expanded`), so the header stays a focusable, toggleable control.
*/
frameless?: boolean;
}
@@ -299,10 +300,17 @@ export function FilesPanel({
const [treeExclude, setTreeExclude] = useState("");
const [debouncedTreeExclude, setDebouncedTreeExclude] = useState("");
const [showSearchFilters, setShowSearchFilters] = useState(false);
// Full-screen drawer mode: forced-open content, no rounded card,
// section grows to fill the parent rather than capping at max-h.
const fullScreen = onClose !== undefined || frameless === true;
const contentVisible = !collapsed || fullScreen;
// The drawer (onClose) owns the full viewport, so it gets a static,
// always-open header with its own X close button — a collapse chevron there
// would be a no-op. The inline rail (frameless) and the standalone card keep
// the collapsible "Working folder" *button* header (accessible name +
// aria-expanded), which is what lets it be focused/toggled and asserted on.
const isDrawer = onClose !== undefined;
// Both the drawer and the inline rail fill their parent's height and drop the
// rounded card chrome; only the standalone card caps content at max-h.
const fillHeight = isDrawer || frameless === true;
// The drawer is always open; everywhere else the header chevron toggles it.
const contentVisible = isDrawer || !collapsed;
const changedQuery = useWorkspaceChangedFiles(conversationId, {
enabled: contentVisible,
});
@@ -357,12 +365,12 @@ export function FilesPanel({
<div
className={cn(
"@container/filespanel overflow-hidden bg-card",
fullScreen ? "flex h-full min-h-0 flex-col" : "flex min-h-0 flex-col",
fillHeight ? "flex h-full min-h-0 flex-col" : "flex min-h-0 flex-col",
)}
>
{/* Header — single row: [title · workingDir] [eye] [chevron / close] */}
<div className="flex shrink-0 items-center gap-2 px-3 py-2">
{fullScreen ? (
{isDrawer ? (
<>
<span className="shrink-0 font-medium text-sm">Working folder</span>
{workingDir && <WorkingDirLabel dir={workingDir} />}
@@ -517,7 +525,7 @@ export function FilesPanel({
className={cn(
"overflow-y-auto px-2 pb-2",
flatView ? "pt-1" : "pt-2",
fullScreen ? "min-h-0 flex-1" : "max-h-72",
fillHeight ? "min-h-0 flex-1" : "max-h-72",
)}
>
{flatView ? (
@@ -0,0 +1,64 @@
"""E2E: the Files rail "Working folder" header is a collapsible button.
The desktop Workspace rail renders ``FilesPanel`` in its ``frameless``
(inline) mode. That must NOT downgrade the working-folder header to a plain
label: it stays an interactive ``button`` carrying ``aria-expanded`` so it is
focusable and toggles the file list. Only the mobile/full-screen drawer
(which has its own X close button) uses a static label header.
This is the regression guard for that distinction: ``frameless`` once folded
into a ``fullScreen`` flag that swapped the button for a ``<span>``, so the
rail header silently stopped being a button. No message is sent — the header
and its collapse state are rail state, not a function of any turn — so this
stays a fast, LLM-free check.
"""
from __future__ import annotations
import re
from playwright.sync_api import Page, expect
from tests.e2e_ui.conftest import open_right_rail
def test_files_rail_working_folder_header_is_a_toggle_button(
page: Page,
seeded_session: tuple[str, str],
) -> None:
"""The rail's "Working folder" header is a button whose chevron collapses
the file list and flips ``aria-expanded``."""
base_url, session_id = seeded_session
page.goto(f"{base_url}/c/{session_id}")
# The rail defaults open but is remembered per session; ensure it is open
# so the Files panel header below is reachable. Scope every lookup to the
# desktop "Workspace" rail so it never matches the hidden mobile drawer
# that mirrors the same markup.
open_right_rail(page)
rail = page.get_by_role("complementary", name="Workspace")
# Files is the default rail tab; click it explicitly so the assertion does
# not depend on the remembered tab from a prior session.
rail.get_by_role("tab", name=re.compile("^Files")).click()
# The header is a BUTTON (not a label): substring-matching "Working folder"
# tolerates the trailing working-directory basename the header also renders.
header = rail.get_by_role("button", name=re.compile("Working folder"))
expect(header).to_be_visible(timeout=30_000)
expect(header).to_have_attribute("aria-expanded", "true")
# Expanded: the file-scope switch (Changed | All) is part of the content.
scope = rail.get_by_role("radiogroup", name="File scope")
expect(scope).to_be_visible()
# Collapsing via the header hides the content and flips aria-expanded.
header.click()
expect(header).to_have_attribute("aria-expanded", "false")
expect(scope).to_have_count(0)
# Expanding again restores the content — proving the header drives a real
# collapse toggle, not a one-way no-op.
header.click()
expect(header).to_have_attribute("aria-expanded", "true")
expect(rail.get_by_role("radiogroup", name="File scope")).to_be_visible()