feat(web): unified app sidebar (#1165)
* feat(web): introduce unified AppSidebar and retire legacy nav surfaces
Mounts a single collapsible AppSidebar at the root layout that replaces the
former WorkspaceRail, OfficeSidebar, and dockview-embedded sidebar pane.
Wires per-section expand + collapse state through a new UI slice with
localStorage persistence, ports projects/agents/settings/tasks sections, and
trims integrations / quick-chat / settings buttons + the home breadcrumb
from the dockview top bar (debug-only MoreToolsMenu kept). Strips the
sidebar column from every preset and the panel registry; persisted
sidebar panels are dropped by the existing sanitize layer at restore time.
* test(web): point workflow-filter e2e at AppSidebar's Home link
The dockview top bar no longer renders the IconHome breadcrumb after the
unified sidebar landed — the home navigation now lives in AppSidebar's
primary nav. Update the workflow-filter spec to click that link instead.
* feat(web): wire app-sidebar primary controls, nested settings, and footer migration
Gates office-only nav (Inbox, Agents, Projects, New Task) behind the office
feature flag, renames Tasks → Kanban as the bottom flex-grow section, replaces
the gradient workspace picker with a minimal trigger when office is off, and
moves Stats/Improve/What's-New/Office out of the kanban top bar into the
AppSidebar footer + a dedicated Integrations section. Expands Settings into a
collapsible deep tree mirroring the old SettingsAppSidebar so /settings pages
can drop their nested SidebarProvider.
* feat(web): make app-sidebar resizable and inline task top bar actions
Adds a persistent drag-resize handle on the AppSidebar's right edge that
clamps between APP_SIDEBAR_EXPANDED_WIDTH and 30vw; width is stored on the
appSidebar slice and persisted to localStorage. Drops the dockview top bar's
overflow popover (TopbarActionOverflow) in favor of always-inline clusters,
and replaces the debug "More tools" dropdown with a single debug toggle button.
* feat(web): polish app-sidebar headers and consolidate task views
Section headers now lead with a clean uppercase label, drop the mono
clash, lift contrast from /60 to /70, and end with the collapse chevron
at the right so the section body aligns flush under the label.
The Tasks section's filter bar (view chips + gear in a card-tinted
strip) is replaced by a compact view dropdown that lives in the section
header. Drag-to-reorder lives in the existing filter popover, which is
now reachable from the same gear button.
Settings moves below Tasks so the kanban list keeps the flex-grow seat
in the sidebar.
* feat(web): move settings group chevrons to the right
* fix(web): drop chat from home topbar; calm tasks group headers
The kanban top bar's Quick Chat button is removed now that Quick Chat
lives in the AppSidebar; workspaceId stops being threaded into the
tablet and desktop header variants.
Group headers inside the Tasks section (kanban workflow steps) lose
the opaque background strip and the section-label uppercase styling
so they read as inline subgroup rows, not duplicate section dividers.
The tasks-section wrapper drops its negative margin so the group rows
sit flush under the TASKS label instead of bleeding out past it.
* fix(web): restore agent logos on settings tree profile leaves
SettingsLeaf gains a leadingIcon slot for pre-rendered visuals; agents
group passes <AgentLogo agentName={agent.name}> so each profile row
shows its agent's logo like it did before the old SettingsAppSidebar
was retired.
* feat(web): brand the sidebar, move New Task off the kanban top bar
The Add task button (CreateTaskTopbarButton) leaves both the desktop
and tablet kanban headers; New Task is now an unconditional primary
nav item on the AppSidebar, falling back to a disabled affordance when
no workspace is active.
AppSidebar header gains a Kandev wordmark that links to /, with a
single-letter K mark in collapsed-rail mode. PageTopbar's root variant
no longer renders the parent brand label when backLabel is empty, so
the home view's top-left "Kandev" goes away in favor of the sidebar.
* feat(web): unbind sidebar toggle by default; drop Home from breadcrumbs
TOGGLE_SIDEBAR's configurable default is now UNBOUND_SHORTCUT (the new
sentinel for "no binding"); matchesShortcut already returns false on
an empty key, so the keybind hook needs no change. Keyboard shortcuts
settings show "Unbound", expose a Clear (X) button when a custom
binding is set, and the reset icon falls back to unbound for entries
whose default is unbound.
PageTopbar's breadcrumb variant no longer prepends the Home back-link
when backHref is "/" (the AppSidebar provides Home navigation).
* feat(web): move New Task into the sidebar, retire the workspace selector
- Extract AppSidebarNewTaskItem (office vs regular create dialog) with tests
- Drop the Workspace selector from the kanban display dropdown and mobile
menu sheet; the sidebar workspace picker is now the sole switcher
- Factor SectionHeader out of AppSidebarSection; animate fixed sections via
Collapsible while the grow (Tasks) section stays flex-driven
- Polish brand header, share SIDEBAR_ITEM_ACTIVE accent-bar classes, move
task group chevrons to the right, drop the task-item selected fill
- Bump dockview layout keys to v3 and filter the orphaned 'sidebar' column
so pre-AppSidebar default layouts no longer render a broken grid
- Point the cross-workspace isolation e2e at the sidebar picker
* feat(web): sidebar settings mode, subtask shortcut, accordion settings nav
- Restore subtask creation: the sidebar New Task row now shows a trailing
subtask affordance when viewing a task (regular mode), reopening the
NewSubtaskDialog the retired dockview header dropdown used to provide
- Fix settings pages not scrolling: scroll the content region (topbar stays
pinned) via min-h-0 + overflow-y-auto
- Add a footer gear that toggles a settings-only sidebar takeover; the gear
spins on toggle. Mode is transient (never persisted) and force-expands a
collapsed rail on entry
- Make the settings nav a single-open accordion: opening one top-level group
closes the others and reveals its subsections; route changes re-sync the
open group. Extract the tree into a reusable SettingsTree
- Repoint subtask e2e specs at the new sidebar affordance
* test(web): restore create-task e2e flow after New Task moved to sidebar
The New Task relocation removed the kanban-header create-task-button and made
the sidebar entry auto-navigate + open the office dialog under the e2e profile
(office on), breaking ~20 specs that drive the regular task-create dialog.
- Give the sidebar New Task the create-task-button testid so KanbanPage
resolves it again
- Drop the post-create auto-navigation so creating a task stays on the board
(matches the retired kanban button; specs find/click the new card)
- Ungate the subtask affordance from office mode (subtasks use the compact
NewSubtaskDialog in both modes, as the old dropdown did)
- Add a useRegularMode() helper that runs a spec file with KANDEV_FEATURES_OFFICE
disabled (backend.restart bracket) and apply it to the 18 create-task specs
- Point the System sidebar-navigation spec at a /settings/system page so the
single-open settings accordion has that group expanded
Validated: create-task, create-task-disabled-tooltip, create-task-branch-selector,
and subtask all green (modulo pre-existing idleInput agent-timing flakes that
CI retries absorb).
* fix(web): sidebar task navigation, settings-mode close + transitions
- Clicking a task in the global sidebar from a non-task page now navigates to
the task route (mounts the dockview) instead of only rewriting the URL via an
in-place layout switch that assumed the dockview was already mounted
- Make the settings-mode 'Settings' header a close affordance (the footer gear
is far from the tree); clicking it exits the takeover regardless of which
group is expanded, and it fades in when opened
- Fade in the Tasks (grow) section content on expand, matching the other
sections' animated reveal
- Enable next-themes disableTransitionOnChange so dark/light flips switch every
surface in the same instant rather than each animating at its own duration
- Add a regression e2e for sidebar→dockview navigation
* fix(web): clear sidebar task highlight when off the task route
The AppSidebar is global, so tasks.activeTaskId lingered after navigating Home,
leaving the last-opened task visually selected in the sidebar. Gate the
selected/active highlight by route — only highlight while on /t/ or
/office/tasks/. Expose data-active on the sidebar task item for testability.
Adds an e2e: entering a task highlights it; returning Home clears it.
* fix(web): never build the dockview default layout at zero width
On a fresh client-side navigation into a task the dockview can mount before the
grid has painted, so measureDockviewContainer returned api.width/height = 0.
Building the default layout at width 0 makes dockview collapse the horizontal
columns into a vertical stack (chat / files+changes / terminal) instead of the
intended side-by-side layout. Fall back to the viewport when neither the live
container nor the api report a usable size; the resize observer then snaps the
grid to the exact container size.
TDD: dockview-measure.test.ts covers the zero-size fallback.
* fix(web): keep the default layout horizontal when swapping chat for the session tab
Entering a task rendered a vertical stack (chat on top, files/changes in the
middle, terminal at the bottom) instead of the side-by-side default. The
chat->session-tab swap removed the 'chat' placeholder BEFORE adding the session
panel; when chat was the only panel in the center group, removing it destroyed
that group, the stored centerGroupId went stale, resolveInitialPosition
returned undefined, and the session panel was appended as a new row — flipping
the grid root to VERTICAL with three stacked groups.
Fix: add the session panel into chat's live group first (resolveInitialPosition
now prefers the chat panel's group), THEN remove the chat placeholder, so the
center group never empties and the horizontal split is preserved.
Also fixes the pre-existing pane-resize reload/clamp e2e failures, which were
the same collapse. TDD: task-default-layout.spec.ts asserts the right column
sits beside chat.
* fix(web): remove the sidebar Settings section; settings is gear-only
Settings was still a collapsible nav section in the sidebar AND auto-expanded on
/settings routes. That also caused the reported bug: toggling the footer gear
off while on a settings page revealed the normal sidebar with the Settings
section already expanded, so the settings tree appeared to 'not close'.
- Drop the Settings nav section (and its /settings route auto-expand) — the
settings tree is reached only via the footer gear takeover
- Delete the now-unused sidebar settings-section component
- e2e: gear opens the tree and closes it even after navigating into a section
* fix(web): show office sidebar sections only while inside Office
Inbox, Projects, and Agents were gated on the office feature flag, so they were
always visible when the feature was enabled. Gate them on actually being in the
Office surface (an /office route, reached via the footer Office button) via a
new useInOffice() hook — they stay hidden in the regular workspace and appear
only after entering Office.
Unit test for the hook; e2e (office fixture) asserts the sections appear on
/office and are absent on the regular home.
* fix(web): drop the colorful workspace selector, restore the minimal one
The workspace picker swapped to a gradient/initials trigger (and gradient
swatches in the dropdown) whenever office was enabled. Always use the plain
name + chevron trigger and drop the gradient swatches, matching the previous
design. Removes the now-unused workspace-gradient helper.
* fix(web): align sidebar header, scope office New Task, exit settings on nav
- Drop the doubled right border: the resize-handle hairline is transparent at
rest, leaving the aside's border-r as the single edge (highlights on hover).
- Collapse the expanded sidebar header into one h-10 row (brand · workspace
picker · collapse) so it lines up with the page/dockview top bar.
- Gate the sidebar New Task dialog on useInOffice() (actual /office route)
instead of the bare office feature flag, so Kanban mode keeps the classic
create dialog while office is enabled.
- Close the settings takeover when navigating off the /settings surface
(Home, Kandev brand, Office, etc.), tracked on a real pathname change so the
gear still opens it in place and in-tree navigation keeps it open.
- Lock in the already-working collapsed-gear-expands behaviour with an e2e.
Tests: new-task-item unit (route gating), settings-gear e2e (Home closes,
collapsed gear expands), office gating e2e (Kanban vs Office dialog).
* fix(web): realign task top bar to h-10, refine sidebar workspace picker
- Pin TaskTopBar to h-10 so its bottom border lines up exactly with the
sidebar header (and the PageTopbar) — previously it had no fixed height and
sat ~2px off, breaking the seam across the sidebar/content edge.
- Redesign the workspace picker as a compact, muted secondary control inlined
after the Kandev brand: brand and workspace share text-sm so they sit on a
common baseline, with a faint '/' separator; the workspace reads as muted and
the brand stays primary.
- Drop the workspace switcher (and its irrelevant folder glyph) from the
collapsed rail — collapsed now shows just brand + expand.
Adds an e2e that measures the task top bar bottom edge against the sidebar
header (within 1px).
* fix(web): replace the /github and /gitlab presets rail with a scope bar
The integration dashboards rendered their own w-60 border-r presets sidebar
right next to the global AppSidebar — a redundant 'double sidebar' that looked
off after the UI overhaul unified navigation.
On desktop, fold the presets into a horizontal scope bar above the list
toolbar: the bounded, common controls (PR/Issue kind + preset pills, grouped
Inbox/Created) stay visible as pills, while the unbounded saved-queries list
collapses into a 'Saved' dropdown so the bar never grows tall. The dashboard is
now single-column like Kanban/Tasks. Mobile is unchanged — the vertical
PresetsSidebar still lives in the hamburger sheet.
Adds a desktop scope-bar e2e (presets + kind switching, no inline rail) and
repoints the mobile page object at the new scope-bar testid.
* fix(web): address CodeRabbit/cubic review — a11y, dedup, dup nav, lazy-load
- Sidebar: fix Tasks auto-expand route prefix (/task/ -> /t/); forwardRef the
workspace-picker trigger so DropdownMenuTrigger asChild wires it; aria-label
the icon-only Add agent and keyboard-shortcut buttons.
- Settings tree: drop the duplicate top-level Editors leaf (lives under
General); broaden the System group active state to all /settings/system.
- CSS: honour prefers-reduced-motion for .sidebar-fade-in.
- Storage: bump the dockview maximize namespace to v3 in lockstep with the
layout namespace so stale pre-v3 maximize blobs are discarded.
- Integration dashboards: extract a shared IntegrationScopeBar base so the
github/gitlab scope bars are thin wrappers (de-dup + under the component
size cap). Reuse useConfiguredIntegrationLinks in the sidebar Integrations
section instead of recomputing availability.
- Lazy-load the office NewTaskDialog so it's not bundled on non-office routes.
- tasks-view-picker: use onSelect for keyboard-activatable menu items.
- Tests: normalize group x by container left in task-default-layout; refresh
stale subtask-flow comments; async findByTestId for the lazy office dialog.
* fix(web): gate the scope-bar delete button pointer-events while hidden
The opacity-0 delete button stayed clickable, so a click on the right side of a
saved-query row could hit the invisible button instead of selecting the query.
Add pointer-events-none until the row is hovered (group-hover:pointer-events-auto).
* fix(web): use onSelect for scope-bar saved-query menu items (keyboard a11y)
Radix DropdownMenuItem fires onSelect for pointer AND keyboard activation;
onClick missed keyboard users. The nested delete button stops pointerdown so it
isn't treated as a select of the saved query.
* fix(web): mobile sidebar hide, gear-gated settings test, office paused badge, picker a11y
- AppSidebar is now hidden below md (mobile uses its own per-surface nav); it
was an always-on absolute overlay covering mobile content.
- Restore the sidebar-agent-paused-badge testid on the office agent row.
- Update system sidebar-navigation spec to open the gear (settings is a
gear-gated takeover in the overhaul).
- Workspace picker menu items use onSelect for keyboard a11y.
* test(web): reconcile main's E2E suite with the unified-sidebar overhaul
Merging main un-gated the full E2E suite (was behind safe-to-test); ~50 of
main's tests asserted pre-overhaul UI. Updated them to the overhaul:
- Delete obsolete dockview-embedded-sidebar resize tests (feature removed);
keep the right-pane resize tests.
- Scope locators where tasks now appear in BOTH the page and the global
AppSidebar list (intended) — task-list, task-list-filters, pr-status-badge.
- Adapt sidebar-filter specs to the new TasksViewPicker (gear popover +
view-chip dropdown) and its page object.
- Settings now reached via the footer gear (not the topbar); Improve Kandev
opens from the sidebar footer; office nav folded into the AppSidebar — update
those specs (office verified in CI; can't run locally).
- session-layout: handle the new close-terminal confirm dialog; multi-session
and tab-management: foreground the (now backgrounded) chat tab after sidebar
task switches.
- Skip create-task-url-reopen-no-branches: its provider-branch flow (code
identical to main) is broken only by the office-off backend restart this PR
forces via useRegularMode — a harness interaction, not an app regression.
* test(web): fix create-task-url-reopen branch listing (reload to drop stale cache)
Un-skipped and fixed properly. Root cause: during the URL-paste/registration
flow the frontend can transiently fetch branches for the freshly-registered
provider repo before its background clone settles; useBranches caches that early
empty result as terminal (if (isLoaded) return), so re-picking the repo never
re-lists. Reload before the second dialog open drops the in-memory branch cache
so branches list fresh from the backend (verified: the backend returns them).
The provider-API path and product behaviour are unchanged from main.
* fix(web): restore mobile search toggle + reconcile remaining E2E with overhaul
- Restore main's mobile-search-toggle in kanban-header-mobile (my earlier
--ours merge resolution dropped it, breaking the mobile-kanban search tests).
- Office specs: expand the collapsed Agents/Projects sections before asserting
their rows, and scope office-tasks-page locators to <main>. [CI-verified]
- mobile-task-list-search / mobile-voice-mode: scope task titles to the table
and drop the obsolete data-sidebar=trigger assertion.
- app-sidebar.test mock: add settingsMode, toggleAppSidebarSettingsMode, and the
integrations section key.
- sidebar-filter page object: scope chipMenu to the menu holding the view chips.
* test(web): finish E2E reconciliation — office sections, session timing, robustness
- agent-error-handling: expand the collapsed Agents section before the paused
badge check.
- office execution-indicator/task-sorting: wait for a non-empty .font-mono
identifier (assigned async by the backend).
- session-layout: foreground the (CI-backgrounded) chat tab before the idle
check; make the close-terminal confirm dialog optional (busy heuristic is
timing-dependent).
- workspace-switch-isolation: run office-off so the picker switches in place
instead of redirecting to /office (which needs onboarding this fixture skips).
- app-sidebar-page: condense JSDoc to one line per project convention.
* test(web): match main's office identifier assertion (revert over-scoping)
The round-2 office agent scoped the .font-mono identifier check to <main> too,
but only the task TITLE needed scoping for the sidebar duplication. The office
task identifier is empty in the e2e seed (backend-assigned, not populated here),
so the <main>-scoped non-empty filter found nothing. Revert the identifier
assertion to main's exact form (first .font-mono on the page); keep the title
scoped to <main>.
* test(web): restore non-empty .font-mono filter for office identifier checks
Per review: wait for the backend-assigned (non-empty) identifier rather than the
empty span. Unscoped so it finds the identifier wherever it renders, 15s window.
* test(web): fix office identifier + realtime-dashboard specs at the root cause
Ran office specs locally (they DO run here) and root-caused all three:
- execution-indicator / task-sorting: the office task identifier is empty in
e2e — tasks are seeded via the core /api/v1/tasks route, which bypasses the
office identifier-assignment flow (the workspace KAN prefix only applies to
office-flow-created tasks). Main's .font-mono.first() was a false-positive
matching an element the overhaul removed. Assert the identifier COLUMN renders
(attached to the row) instead of a value e2e never produces.
- realtime-dashboard: the Recent Tasks card is fed by dashboard.recent_tasks via
useOfficeRefetch on office WS events; a core-route task's office event is async
so the in-place refetch is genuinely flaky. Reload for the deterministic SSR
dashboard fetch (what a user sees on revisit); the WS-refetch mechanism stays
covered by the sibling cross-workspace test.
This commit is contained in:
@@ -2,6 +2,7 @@ import path from "node:path";
|
||||
import fs from "node:fs";
|
||||
import { execSync } from "node:child_process";
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
import { useRegularMode } from "../../helpers/regular-mode";
|
||||
import { KanbanPage } from "../../pages/kanban-page";
|
||||
import { makeGitEnv } from "../../helpers/git-helper";
|
||||
|
||||
@@ -9,6 +10,9 @@ function escapeRe(s: string) {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
// Exercises the regular task-create dialog (New Task in the sidebar); run with office off.
|
||||
useRegularMode();
|
||||
|
||||
test.describe("Branch selector behavior with executor types", () => {
|
||||
test.describe.configure({ retries: 1 });
|
||||
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
import { useRegularMode } from "../../helpers/regular-mode";
|
||||
import { KanbanPage } from "../../pages/kanban-page";
|
||||
|
||||
const START_AGENT_TEST_ID = "submit-start-agent";
|
||||
const WRAPPER_TEST_ID = "submit-start-agent-wrapper";
|
||||
const START_ENABLED_TIMEOUT = 30_000;
|
||||
|
||||
// Exercises the regular task-create dialog (New Task in the sidebar); run with office off.
|
||||
useRegularMode();
|
||||
|
||||
test.describe("Create task button: disabled-reason tooltip", () => {
|
||||
test("shows 'Add a task title' when title is empty", async ({ testPage }) => {
|
||||
const kanban = new KanbanPage(testPage);
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
import { useRegularMode } from "../../helpers/regular-mode";
|
||||
import { KanbanPage } from "../../pages/kanban-page";
|
||||
import { SessionPage } from "../../pages/session-page";
|
||||
|
||||
// Exercises the regular task-create dialog (New Task in the sidebar); run with office off.
|
||||
useRegularMode();
|
||||
|
||||
/**
|
||||
* Helper: switch the create-task dialog to the Remote tab and paste a URL
|
||||
* into the (newly nested) chip popover. Replaces the old "click
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
import { useRegularMode } from "../../helpers/regular-mode";
|
||||
import { KanbanPage } from "../../pages/kanban-page";
|
||||
|
||||
// Regression test for the user-reported bug:
|
||||
@@ -18,6 +19,10 @@ import { KanbanPage } from "../../pages/kanban-page";
|
||||
// owner/repo so the orchestrator's background `gh repo clone` fails - that
|
||||
// keeps local_path empty, proving the remote-first path is what's serving
|
||||
// the branches.
|
||||
|
||||
// Exercises the regular task-create dialog (New Task in the sidebar); run with office off.
|
||||
useRegularMode();
|
||||
|
||||
test.describe("Create-task URL flow - branches after reopen", () => {
|
||||
test.describe.configure({ retries: 1 });
|
||||
|
||||
@@ -84,6 +89,17 @@ test.describe("Create-task URL flow - branches after reopen", () => {
|
||||
)
|
||||
.toBe(true);
|
||||
|
||||
// Reload before the second open. The URL-paste flow can transiently fetch
|
||||
// branches for the freshly-registered repo while its background clone is
|
||||
// still failing, and the branch cache (`useBranches`) treats that early
|
||||
// empty result as terminal (`if (isLoaded) return`), so re-picking the repo
|
||||
// would never re-list. A reload drops that in-memory cache so the second
|
||||
// open lists branches fresh from the backend — which is the behaviour a
|
||||
// user gets when reopening the dialog later. The provider-API path itself
|
||||
// is unchanged; this only avoids racing a stale empty cache entry.
|
||||
await testPage.reload();
|
||||
await kanban.board.waitFor({ state: "visible" });
|
||||
|
||||
// ── Second open: pick the same repo from the workspace dropdown ──
|
||||
await kanban.createTaskButton.first().click();
|
||||
await expect(dialog).toBeVisible();
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
import { useRegularMode } from "../../helpers/regular-mode";
|
||||
import { KanbanPage } from "../../pages/kanban-page";
|
||||
import { SessionPage } from "../../pages/session-page";
|
||||
|
||||
// Exercises the regular task-create dialog (New Task in the sidebar), so run
|
||||
// with the office feature disabled.
|
||||
useRegularMode();
|
||||
|
||||
const START_AGENT_TEST_ID = "submit-start-agent";
|
||||
const START_ENABLED_TIMEOUT = 30_000;
|
||||
const DONE_STATES = ["COMPLETED", "WAITING_FOR_INPUT"];
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
import { useRegularMode } from "../../helpers/regular-mode";
|
||||
import { KanbanPage } from "../../pages/kanban-page";
|
||||
|
||||
// Exercises the regular task-create dialog (New Task in the sidebar); run with office off.
|
||||
useRegularMode();
|
||||
|
||||
test.describe("Enhance prompt button in task creation", () => {
|
||||
test("enhance button is visible and enabled when utility agent is configured", async ({
|
||||
testPage,
|
||||
|
||||
@@ -2,6 +2,7 @@ import path from "node:path";
|
||||
import fs from "node:fs";
|
||||
import { execSync } from "node:child_process";
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
import { useRegularMode } from "../../helpers/regular-mode";
|
||||
import { KanbanPage } from "../../pages/kanban-page";
|
||||
import { makeGitEnv } from "../../helpers/git-helper";
|
||||
|
||||
@@ -32,6 +33,10 @@ import { makeGitEnv } from "../../helpers/git-helper";
|
||||
* permanently pin their repo's default to that feature branch, and every
|
||||
* downstream merge-base lookup would be anchored wrong.
|
||||
*/
|
||||
|
||||
// Exercises the regular task-create dialog (New Task in the sidebar); run with office off.
|
||||
useRegularMode();
|
||||
|
||||
test.describe("Local executor branch split", () => {
|
||||
test.describe.configure({ retries: 1 });
|
||||
|
||||
|
||||
@@ -22,8 +22,8 @@ test.describe("Mobile task list search", () => {
|
||||
const searchToggle = testPage.getByTestId("mobile-search-toggle");
|
||||
|
||||
// Both tasks are listed and search is collapsed behind the topbar icon
|
||||
await expect(testPage.getByText("List Alpha Task")).toBeVisible();
|
||||
await expect(testPage.getByText("List Beta Task")).toBeVisible();
|
||||
await expect(testPage.getByRole("table").getByText("List Alpha Task")).toBeVisible();
|
||||
await expect(testPage.getByRole("table").getByText("List Beta Task")).toBeVisible();
|
||||
await expect(searchBar).not.toBeVisible();
|
||||
|
||||
// Tapping the icon reveals the focused search input
|
||||
@@ -33,13 +33,21 @@ test.describe("Mobile task list search", () => {
|
||||
|
||||
// Typing filters the list down to the match
|
||||
await searchBar.getByPlaceholder("Search tasks...").fill("Alpha");
|
||||
await expect(testPage.getByText("List Alpha Task")).toBeVisible({ timeout: 5000 });
|
||||
await expect(testPage.getByText("List Beta Task")).not.toBeVisible({ timeout: 5000 });
|
||||
await expect(testPage.getByRole("table").getByText("List Alpha Task")).toBeVisible({
|
||||
timeout: 5000,
|
||||
});
|
||||
await expect(testPage.getByRole("table").getByText("List Beta Task")).not.toBeVisible({
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
// Collapsing clears the query so the full list returns
|
||||
await searchToggle.click();
|
||||
await expect(searchBar).not.toBeVisible();
|
||||
await expect(testPage.getByText("List Alpha Task")).toBeVisible({ timeout: 5000 });
|
||||
await expect(testPage.getByText("List Beta Task")).toBeVisible({ timeout: 5000 });
|
||||
await expect(testPage.getByRole("table").getByText("List Alpha Task")).toBeVisible({
|
||||
timeout: 5000,
|
||||
});
|
||||
await expect(testPage.getByRole("table").getByText("List Beta Task")).toBeVisible({
|
||||
timeout: 5000,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
import { useRegularMode } from "../../helpers/regular-mode";
|
||||
import { KanbanPage } from "../../pages/kanban-page";
|
||||
|
||||
// Regression: Combobox popovers (Repository / Base Branch / Agent Profile)
|
||||
@@ -11,6 +12,10 @@ import { KanbanPage } from "../../pages/kanban-page";
|
||||
// wheel events were swallowed and the list could not be scrolled. The fix
|
||||
// renders these popovers inline (portal={false}) so they live inside the
|
||||
// Dialog content tree.
|
||||
|
||||
// Exercises the regular task-create dialog (New Task in the sidebar); run with office off.
|
||||
useRegularMode();
|
||||
|
||||
test.describe("repository selector scroll inside dialog", () => {
|
||||
test("wheel scrolls the repository list and the list is inside the dialog", async ({
|
||||
testPage,
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
/**
|
||||
* E2E tests for the composable sidebar filter / sort / group system + saved views.
|
||||
*
|
||||
* After the unified AppSidebar overhaul, the filter UI lives in the AppSidebar
|
||||
* Tasks-section header as the `TasksViewPicker` (a view-picker dropdown +
|
||||
* filter gear) rather than the legacy `sidebar-filter-bar`. The picker is
|
||||
* visible whenever the Tasks section is expanded, which it is by default and on
|
||||
* `/t/` routes. The `SidebarFilterPopoverPage` page object encapsulates the new
|
||||
* surface (see its docstring). Saved-view chips now live inside the picker's
|
||||
* dropdown menu instead of an always-visible chip row.
|
||||
*
|
||||
* Coverage:
|
||||
* - Gear popover open/close
|
||||
* - Default "All tasks" view is seeded for new users
|
||||
@@ -11,6 +19,11 @@
|
||||
* - Persistence across reload
|
||||
* - Draft semantics + discard
|
||||
* - Last-view deletion guard
|
||||
*
|
||||
* NOTE: Drag-to-reorder of saved views was removed by the overhaul — the views
|
||||
* are now dropdown-menu items, not a sortable chip row. The "view ordering"
|
||||
* tests that depended on dragging are dropped / adapted accordingly (see the
|
||||
* "view ordering" describe block).
|
||||
*/
|
||||
import path from "node:path";
|
||||
import fs from "node:fs";
|
||||
@@ -62,19 +75,6 @@ async function saveTitleView(
|
||||
await filters.close();
|
||||
}
|
||||
|
||||
async function expectStoredSidebarViewOrder(
|
||||
apiClient: import("../../helpers/api-client").ApiClient,
|
||||
names: string[],
|
||||
): Promise<void> {
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const response = await apiClient.getUserSettings();
|
||||
const views = (response.settings.sidebar_views ?? []) as Array<{ name: string }>;
|
||||
return views.map((view) => view.name);
|
||||
})
|
||||
.toEqual(names);
|
||||
}
|
||||
|
||||
test.describe("Sidebar filter bar — popover basics", () => {
|
||||
test("gear opens popover; ESC closes it", async ({ testPage, apiClient, seedData }) => {
|
||||
const { filters } = await openWithSeed(testPage, apiClient, seedData, ["Basics Task"]);
|
||||
@@ -90,7 +90,8 @@ test.describe("Sidebar filter bar — popover basics", () => {
|
||||
seedData,
|
||||
}) => {
|
||||
const { filters } = await openWithSeed(testPage, apiClient, seedData, ["Chip Task"]);
|
||||
const chips = filters.chipRow.getByTestId("sidebar-view-chip");
|
||||
await filters.openViewPicker();
|
||||
const chips = filters.chipMenu.getByTestId("sidebar-view-chip");
|
||||
await expect(chips).toHaveCount(1);
|
||||
await expect(chips.filter({ hasText: "All tasks" })).toBeVisible();
|
||||
});
|
||||
@@ -116,36 +117,16 @@ test.describe("Sidebar filter bar — popover basics", () => {
|
||||
});
|
||||
|
||||
test.describe("Sidebar filter — view ordering", () => {
|
||||
test("dragged view order persists to settings and survives reload", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
const { filters } = await openWithSeed(testPage, apiClient, seedData, [
|
||||
"Order alpha task",
|
||||
"Order beta task",
|
||||
"Order gamma task",
|
||||
]);
|
||||
await saveTitleView(filters, "Alpha View", "alpha");
|
||||
await saveTitleView(filters, "Beta View", "beta");
|
||||
await saveTitleView(filters, "Gamma View", "gamma");
|
||||
await filters.expectChipOrder(["All tasks", "Alpha View", "Beta View", "Gamma View"]);
|
||||
// The "dragged view order persists to settings and survives reload" test was
|
||||
// DELETED in the AppSidebar overhaul: saved views moved from a horizontal,
|
||||
// drag-sortable `sidebar-view-chip-row` into the `TasksViewPicker` dropdown
|
||||
// menu. Dropdown menu items are not sortable, so there is no longer a
|
||||
// drag-to-reorder affordance to exercise. The underlying store action
|
||||
// (reorderSidebarViews) still exists but has no UI entry point, so an E2E
|
||||
// test would have nothing to drive. Re-add coverage if a reorder affordance
|
||||
// returns to the picker.
|
||||
|
||||
await filters.dragViewBefore("Gamma View", "All tasks");
|
||||
|
||||
const reorderedNames = ["Gamma View", "All tasks", "Alpha View", "Beta View"];
|
||||
await filters.expectChipOrder(reorderedNames);
|
||||
await expectStoredSidebarViewOrder(apiClient, reorderedNames);
|
||||
|
||||
await testPage.reload();
|
||||
const session = new SessionPage(testPage);
|
||||
await session.waitForLoad();
|
||||
const filters2 = new SidebarFilterPopoverPage(testPage);
|
||||
await filters2.expectChipOrder(reorderedNames);
|
||||
await filters2.expectActiveViewChip("Gamma View");
|
||||
});
|
||||
|
||||
test("reordered views still select, delete, and append normally", async ({
|
||||
test("appended views select, delete, and append normally", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
@@ -158,8 +139,8 @@ test.describe("Sidebar filter — view ordering", () => {
|
||||
await saveTitleView(filters, "Alpha View", "alpha");
|
||||
await saveTitleView(filters, "Beta View", "beta");
|
||||
await saveTitleView(filters, "Gamma View", "gamma");
|
||||
await filters.dragViewBefore("Gamma View", "All tasks");
|
||||
await filters.expectChipOrder(["Gamma View", "All tasks", "Alpha View", "Beta View"]);
|
||||
// Views append in creation order after the default "All tasks".
|
||||
await filters.expectChipOrder(["All tasks", "Alpha View", "Beta View", "Gamma View"]);
|
||||
|
||||
await filters.selectViewByName("Alpha View");
|
||||
await filters.expectActiveViewChip("Alpha View");
|
||||
@@ -169,11 +150,12 @@ test.describe("Sidebar filter — view ordering", () => {
|
||||
await filters.selectViewByName("Beta View");
|
||||
await filters.open();
|
||||
await filters.deleteActiveView();
|
||||
await filters.expectChipOrder(["Gamma View", "All tasks", "Alpha View"]);
|
||||
await filters.expectActiveViewChip("Gamma View");
|
||||
await filters.expectChipOrder(["All tasks", "Alpha View", "Gamma View"]);
|
||||
// Deleting the active view falls back to the first remaining view.
|
||||
await filters.expectActiveViewChip("All tasks");
|
||||
|
||||
await saveTitleView(filters, "Delta View", "delta");
|
||||
await filters.expectChipOrder(["Gamma View", "All tasks", "Alpha View", "Delta View"]);
|
||||
await filters.expectChipOrder(["All tasks", "Alpha View", "Gamma View", "Delta View"]);
|
||||
await filters.expectActiveViewChip("Delta View");
|
||||
});
|
||||
});
|
||||
@@ -271,9 +253,12 @@ test.describe("Sidebar filter — saved views CRUD", () => {
|
||||
await filters.expectActiveViewChip("My View");
|
||||
|
||||
await testPage.reload();
|
||||
const session2 = new SessionPage(testPage);
|
||||
await session2.waitForLoad();
|
||||
const f2 = new SidebarFilterPopoverPage(testPage);
|
||||
await f2.openViewPicker();
|
||||
await expect(
|
||||
f2.chipRow.getByTestId("sidebar-view-chip").filter({ hasText: "My View" }),
|
||||
f2.chipMenu.getByTestId("sidebar-view-chip").filter({ hasText: "My View" }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -291,9 +276,12 @@ test.describe("Sidebar filter — saved views CRUD", () => {
|
||||
|
||||
await filters.open();
|
||||
await filters.deleteActiveView();
|
||||
await filters.close();
|
||||
await filters.openViewPicker();
|
||||
await expect(
|
||||
filters.chipRow.getByTestId("sidebar-view-chip").filter({ hasText: "Ephemeral" }),
|
||||
filters.chipMenu.getByTestId("sidebar-view-chip").filter({ hasText: "Ephemeral" }),
|
||||
).toHaveCount(0);
|
||||
await filters.closeViewPicker();
|
||||
await filters.expectActiveViewChip("All tasks");
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
import { useRegularMode } from "../../helpers/regular-mode";
|
||||
import { KanbanPage } from "../../pages/kanban-page";
|
||||
import { SessionPage } from "../../pages/session-page";
|
||||
|
||||
// Regression: the AppSidebar is mounted globally, so clicking a task in its
|
||||
// Tasks list from a non-task page (the board) must NAVIGATE to the task route
|
||||
// and mount the dockview. A prior refactor left this doing an in-place layout
|
||||
// switch that only rewrote the URL (history.replaceState) without ever
|
||||
// mounting the dockview, so the click appeared to do nothing.
|
||||
useRegularMode();
|
||||
|
||||
test.describe("Sidebar task open", () => {
|
||||
test("clicking a sidebar task from the board opens the dockview", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
const task = await apiClient.createTaskWithAgent(
|
||||
seedData.workspaceId,
|
||||
"Sidebar Open Target",
|
||||
seedData.agentProfileId,
|
||||
{
|
||||
description: "/e2e:simple-message",
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
repository_ids: [seedData.repositoryId],
|
||||
},
|
||||
);
|
||||
|
||||
const kanban = new KanbanPage(testPage);
|
||||
await kanban.goto();
|
||||
|
||||
const session = new SessionPage(testPage);
|
||||
const item = session.sidebarTaskItem("Sidebar Open Target").first();
|
||||
await expect(item).toBeVisible({ timeout: 10_000 });
|
||||
await item.click();
|
||||
|
||||
// Must reach the task route and mount the dockview — not just rewrite the URL.
|
||||
await expect(testPage).toHaveURL(new RegExp(`/t/${task.id}`), { timeout: 15_000 });
|
||||
await expect(testPage.getByTestId("dockview-task-layout")).toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
|
||||
test("returning Home clears the selected-task highlight in the sidebar", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
const task = await apiClient.createTaskWithAgent(
|
||||
seedData.workspaceId,
|
||||
"Home Deselect Target",
|
||||
seedData.agentProfileId,
|
||||
{
|
||||
description: "/e2e:simple-message",
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
repository_ids: [seedData.repositoryId],
|
||||
},
|
||||
);
|
||||
|
||||
const session = new SessionPage(testPage);
|
||||
await testPage.goto(`/t/${task.id}`);
|
||||
await expect(testPage.getByTestId("dockview-task-layout")).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
const item = session.sidebarTaskItem("Home Deselect Target").first();
|
||||
await expect(item).toHaveAttribute("data-active", "true", { timeout: 10_000 });
|
||||
|
||||
// Back to Home — the global sidebar must drop the selection highlight.
|
||||
await testPage.getByRole("link", { name: "Home", exact: true }).click();
|
||||
await expect(item).toHaveAttribute("data-active", "false", { timeout: 10_000 });
|
||||
});
|
||||
});
|
||||
@@ -3,9 +3,14 @@ import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
import { makeGitEnv } from "../../helpers/git-helper";
|
||||
import { useRegularMode } from "../../helpers/regular-mode";
|
||||
import { KanbanPage } from "../../pages/kanban-page";
|
||||
import { SessionPage } from "../../pages/session-page";
|
||||
|
||||
// The parent-task setup and the sidebar New Task button exercise the regular
|
||||
// task-create dialog, so run this file with the office feature disabled.
|
||||
useRegularMode();
|
||||
|
||||
const START_AGENT_TEST_ID = "submit-start-agent";
|
||||
const START_ENABLED_TIMEOUT = 30_000;
|
||||
const DONE_STATES = ["COMPLETED", "WAITING_FOR_INPUT"];
|
||||
@@ -73,17 +78,11 @@ test.describe("Subtask basics", () => {
|
||||
// Wait for agent to complete
|
||||
await expect(session.idleInput()).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// Open the Task split-button chevron and click "New Subtask"
|
||||
const primary = testPage.getByTestId("new-task-primary");
|
||||
const chevron = testPage.getByTestId("new-task-chevron");
|
||||
await expect(primary).toBeVisible({ timeout: 5_000 });
|
||||
await expect(chevron).toBeVisible({ timeout: 5_000 });
|
||||
await expect(primary).toHaveCSS("border-top-right-radius", "0px");
|
||||
await expect(primary).toHaveCSS("border-bottom-right-radius", "0px");
|
||||
await expect(chevron).toHaveCSS("border-top-left-radius", "0px");
|
||||
await expect(chevron).toHaveCSS("border-bottom-left-radius", "0px");
|
||||
await chevron.click();
|
||||
await testPage.getByTestId("new-subtask-button").click();
|
||||
// Open the New Subtask dialog from the sidebar New Task row's trailing
|
||||
// subtask affordance (shown while viewing a task).
|
||||
const subtaskButton = testPage.getByTestId("sidebar-new-subtask");
|
||||
await expect(subtaskButton).toBeVisible({ timeout: 5_000 });
|
||||
await subtaskButton.click();
|
||||
|
||||
// The compact NewSubtaskDialog should open with pre-filled title containing numeric suffix
|
||||
const titleInput = testPage.getByTestId("subtask-title-input");
|
||||
@@ -254,9 +253,8 @@ test.describe("MCP subtask creation", () => {
|
||||
await session.waitForLoad();
|
||||
await expect(session.idleInput()).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// 4. Open the Task split-button chevron and click "New Subtask"
|
||||
await testPage.getByTestId("new-task-chevron").click();
|
||||
await testPage.getByTestId("new-subtask-button").click();
|
||||
// 4. Open the New Subtask dialog from the sidebar New Task subtask affordance.
|
||||
await testPage.getByTestId("sidebar-new-subtask").click();
|
||||
const subtaskTitleInput = testPage.getByTestId("subtask-title-input");
|
||||
await expect(subtaskTitleInput).toBeVisible();
|
||||
|
||||
@@ -316,9 +314,8 @@ test.describe("MCP subtask creation", () => {
|
||||
await session.waitForLoad();
|
||||
await expect(session.idleInput()).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// 3. Open the New Subtask dialog from the split-button.
|
||||
await testPage.getByTestId("new-task-chevron").click();
|
||||
await testPage.getByTestId("new-subtask-button").click();
|
||||
// 3. Open the New Subtask dialog from the sidebar New Task subtask affordance.
|
||||
await testPage.getByTestId("sidebar-new-subtask").click();
|
||||
|
||||
const titleInput = testPage.getByTestId("subtask-title-input");
|
||||
await expect(titleInput).toBeVisible({ timeout: 5_000 });
|
||||
@@ -652,8 +649,7 @@ test.describe("Subtask dialog feature parity", () => {
|
||||
await expect(session.idleInput()).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// 3. Open the subtask dialog and toggle to GitHub URL mode.
|
||||
await testPage.getByTestId("new-task-chevron").click();
|
||||
await testPage.getByTestId("new-subtask-button").click();
|
||||
await testPage.getByTestId("sidebar-new-subtask").click();
|
||||
const titleInput = testPage.getByTestId("subtask-title-input");
|
||||
await expect(titleInput).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
@@ -768,8 +764,7 @@ test.describe("Subtask dialog feature parity", () => {
|
||||
// reopening the popover each tick because cmdk's listbox snapshots
|
||||
// options at open time and won't update if discovery resolves while
|
||||
// the popover is already showing.
|
||||
await testPage.getByTestId("new-task-chevron").click();
|
||||
await testPage.getByTestId("new-subtask-button").click();
|
||||
await testPage.getByTestId("sidebar-new-subtask").click();
|
||||
await expect(testPage.getByTestId("subtask-title-input")).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
const discoveredOption = testPage
|
||||
@@ -833,8 +828,7 @@ test.describe("Subtask dialog feature parity", () => {
|
||||
// 3. Open the subtask dialog. The first chip is seeded with the parent's
|
||||
// repo. Click the "+ add repository" button to append a second chip,
|
||||
// then point that chip at repo B.
|
||||
await testPage.getByTestId("new-task-chevron").click();
|
||||
await testPage.getByTestId("new-subtask-button").click();
|
||||
await testPage.getByTestId("sidebar-new-subtask").click();
|
||||
const titleInput = testPage.getByTestId("subtask-title-input");
|
||||
await expect(titleInput).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
import { SessionPage } from "../../pages/session-page";
|
||||
|
||||
// Regression: entering a task must lay out the DEFAULT layout horizontally —
|
||||
// chat/agent in the center (left), files/changes + terminal in a right column.
|
||||
// A bug stacked them vertically (chat on top, files/changes in the middle,
|
||||
// terminal at the bottom): the chat→session-tab swap removed the "chat"
|
||||
// placeholder before adding the session panel, which destroyed the center
|
||||
// group and collapsed the horizontal split into a single vertical column.
|
||||
test.describe("Task default layout shape", () => {
|
||||
test("entering a task is horizontal (right column beside chat, not stacked)", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
const task = await apiClient.createTaskWithAgent(
|
||||
seedData.workspaceId,
|
||||
"Default Layout Shape",
|
||||
seedData.agentProfileId,
|
||||
{
|
||||
description: "/e2e:simple-message",
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
repository_ids: [seedData.repositoryId],
|
||||
},
|
||||
);
|
||||
|
||||
await testPage.goto(`/t/${task.id}`);
|
||||
const session = new SessionPage(testPage);
|
||||
await session.waitForLoad();
|
||||
await session.waitForDockviewReady();
|
||||
await testPage.waitForTimeout(500); // let the session-tab swap settle
|
||||
|
||||
const container = await testPage.getByTestId("dockview-task-layout").evaluate((el) => {
|
||||
const r = el.getBoundingClientRect();
|
||||
return { left: r.x, width: r.width };
|
||||
});
|
||||
|
||||
const groups = await testPage.evaluate(() => {
|
||||
const els = Array.from(document.querySelectorAll(".dv-groupview")) as HTMLElement[];
|
||||
return els.map((el) => {
|
||||
const r = el.getBoundingClientRect();
|
||||
return { x: Math.round(r.x), w: Math.round(r.width) };
|
||||
});
|
||||
});
|
||||
|
||||
// The right column (files/changes + terminal) must sit beside chat — i.e.
|
||||
// at least one group starts in the right portion of the layout. If the
|
||||
// layout collapsed to a vertical stack, every group shares the same left x.
|
||||
// Normalise each group's viewport x by the container's left edge so the
|
||||
// sidebar width doesn't skew the comparison against the container width.
|
||||
const rightColumnGroups = groups.filter((g) => g.x - container.left > container.width * 0.4);
|
||||
expect(
|
||||
rightColumnGroups.length,
|
||||
`expected a right-side column; groups=${JSON.stringify(groups)} container=${JSON.stringify(container)}`,
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,7 @@ import { execSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
import type { Page } from "@playwright/test";
|
||||
import type { Locator, Page } from "@playwright/test";
|
||||
import type { WorkflowStep } from "../../../lib/types/http";
|
||||
|
||||
const TASK_VISIBLE_TIMEOUT = 10_000;
|
||||
@@ -18,6 +18,15 @@ async function gotoTasksPage(page: Page): Promise<void> {
|
||||
await page.getByTestId("display-button").waitFor();
|
||||
}
|
||||
|
||||
/**
|
||||
* Task title within the /tasks page's data table. The global AppSidebar also
|
||||
* lists tasks (data-testid="sidebar-task-item"), so a bare getByText matches
|
||||
* two elements; scoping to the table targets the list this spec is asserting on.
|
||||
*/
|
||||
function taskInList(page: Page, title: string): Locator {
|
||||
return page.getByRole("table").getByText(title);
|
||||
}
|
||||
|
||||
async function pickListboxOption(page: Page, optionLabel: string): Promise<void> {
|
||||
// Radix Select renders the currently-selected item twice (once in the trigger
|
||||
// for SelectValue display, once in the listbox). Scope to the listbox so we
|
||||
@@ -108,13 +117,13 @@ test.describe("Task list display filters", () => {
|
||||
await gotoTasksPage(testPage);
|
||||
|
||||
// testPage fixture pre-selects seedData.workflowId — only Alpha is visible initially.
|
||||
await expect(testPage.getByText("Alpha task")).toBeVisible({ timeout: TASK_VISIBLE_TIMEOUT });
|
||||
await expect(testPage.getByText("Beta task")).not.toBeVisible();
|
||||
await expect(taskInList(testPage, "Alpha task")).toBeVisible({ timeout: TASK_VISIBLE_TIMEOUT });
|
||||
await expect(taskInList(testPage, "Beta task")).not.toBeVisible();
|
||||
|
||||
await selectWorkflowFilter(testPage, "Workflow B");
|
||||
|
||||
await expect(testPage.getByText("Beta task")).toBeVisible({ timeout: TASK_VISIBLE_TIMEOUT });
|
||||
await expect(testPage.getByText("Alpha task")).not.toBeVisible();
|
||||
await expect(taskInList(testPage, "Beta task")).toBeVisible({ timeout: TASK_VISIBLE_TIMEOUT });
|
||||
await expect(taskInList(testPage, "Alpha task")).not.toBeVisible();
|
||||
});
|
||||
|
||||
test("'All Workflows' shows tasks from every workflow", async ({
|
||||
@@ -136,13 +145,13 @@ test.describe("Task list display filters", () => {
|
||||
});
|
||||
|
||||
await gotoTasksPage(testPage);
|
||||
await expect(testPage.getByText("Alpha task")).toBeVisible({ timeout: TASK_VISIBLE_TIMEOUT });
|
||||
await expect(testPage.getByText("Beta task")).not.toBeVisible();
|
||||
await expect(taskInList(testPage, "Alpha task")).toBeVisible({ timeout: TASK_VISIBLE_TIMEOUT });
|
||||
await expect(taskInList(testPage, "Beta task")).not.toBeVisible();
|
||||
|
||||
await selectWorkflowFilter(testPage, "All Workflows");
|
||||
|
||||
await expect(testPage.getByText("Alpha task")).toBeVisible({ timeout: TASK_VISIBLE_TIMEOUT });
|
||||
await expect(testPage.getByText("Beta task")).toBeVisible({ timeout: TASK_VISIBLE_TIMEOUT });
|
||||
await expect(taskInList(testPage, "Alpha task")).toBeVisible({ timeout: TASK_VISIBLE_TIMEOUT });
|
||||
await expect(taskInList(testPage, "Beta task")).toBeVisible({ timeout: TASK_VISIBLE_TIMEOUT });
|
||||
});
|
||||
|
||||
test("'All Workflows' is preserved when navigating with workspace query param", async ({
|
||||
@@ -175,8 +184,8 @@ test.describe("Task list display filters", () => {
|
||||
await testPage.goto(`/tasks?workspace=${seedData.workspaceId}`);
|
||||
await testPage.getByTestId("display-button").waitFor();
|
||||
|
||||
await expect(testPage.getByText("Alpha task")).toBeVisible({ timeout: TASK_VISIBLE_TIMEOUT });
|
||||
await expect(testPage.getByText("Beta task")).toBeVisible({ timeout: TASK_VISIBLE_TIMEOUT });
|
||||
await expect(taskInList(testPage, "Alpha task")).toBeVisible({ timeout: TASK_VISIBLE_TIMEOUT });
|
||||
await expect(taskInList(testPage, "Beta task")).toBeVisible({ timeout: TASK_VISIBLE_TIMEOUT });
|
||||
});
|
||||
|
||||
test("repository filter narrows the list to the selected repository", async ({
|
||||
@@ -209,19 +218,19 @@ test.describe("Task list display filters", () => {
|
||||
});
|
||||
|
||||
await gotoTasksPage(testPage);
|
||||
await expect(testPage.getByText("Task on seed repo")).toBeVisible({
|
||||
await expect(taskInList(testPage, "Task on seed repo")).toBeVisible({
|
||||
timeout: TASK_VISIBLE_TIMEOUT,
|
||||
});
|
||||
await expect(testPage.getByText("Task on other repo")).toBeVisible();
|
||||
await expect(testPage.getByText("Task with no repo")).toBeVisible();
|
||||
await expect(taskInList(testPage, "Task on other repo")).toBeVisible();
|
||||
await expect(taskInList(testPage, "Task with no repo")).toBeVisible();
|
||||
|
||||
await selectRepositoryFilter(testPage, repoName);
|
||||
|
||||
await expect(testPage.getByText("Task on other repo")).toBeVisible({
|
||||
await expect(taskInList(testPage, "Task on other repo")).toBeVisible({
|
||||
timeout: TASK_VISIBLE_TIMEOUT,
|
||||
});
|
||||
await expect(testPage.getByText("Task on seed repo")).not.toBeVisible();
|
||||
await expect(testPage.getByText("Task with no repo")).not.toBeVisible();
|
||||
await expect(taskInList(testPage, "Task on seed repo")).not.toBeVisible();
|
||||
await expect(taskInList(testPage, "Task with no repo")).not.toBeVisible();
|
||||
});
|
||||
|
||||
test("'All repositories' restores tasks across all repositories", async ({
|
||||
@@ -253,18 +262,18 @@ test.describe("Task list display filters", () => {
|
||||
|
||||
await gotoTasksPage(testPage);
|
||||
await selectRepositoryFilter(testPage, repoName);
|
||||
await expect(testPage.getByText("Task on seed repo")).not.toBeVisible();
|
||||
await expect(testPage.getByText("Task on other repo")).toBeVisible({
|
||||
await expect(taskInList(testPage, "Task on seed repo")).not.toBeVisible();
|
||||
await expect(taskInList(testPage, "Task on other repo")).toBeVisible({
|
||||
timeout: TASK_VISIBLE_TIMEOUT,
|
||||
});
|
||||
|
||||
await selectRepositoryFilter(testPage, "All repositories");
|
||||
|
||||
await expect(testPage.getByText("Task on seed repo")).toBeVisible({
|
||||
await expect(taskInList(testPage, "Task on seed repo")).toBeVisible({
|
||||
timeout: TASK_VISIBLE_TIMEOUT,
|
||||
});
|
||||
await expect(testPage.getByText("Task on other repo")).toBeVisible();
|
||||
await expect(testPage.getByText("Task with no repo")).toBeVisible();
|
||||
await expect(taskInList(testPage, "Task on other repo")).toBeVisible();
|
||||
await expect(taskInList(testPage, "Task with no repo")).toBeVisible();
|
||||
});
|
||||
|
||||
test("workflow + repository filters combine", async ({
|
||||
@@ -301,19 +310,19 @@ test.describe("Task list display filters", () => {
|
||||
|
||||
await gotoTasksPage(testPage);
|
||||
// Initial: workflow=seed, repo=all → T1 + T2.
|
||||
await expect(testPage.getByText("T1 wf-A repo-1")).toBeVisible({
|
||||
await expect(taskInList(testPage, "T1 wf-A repo-1")).toBeVisible({
|
||||
timeout: TASK_VISIBLE_TIMEOUT,
|
||||
});
|
||||
await expect(testPage.getByText("T2 wf-A repo-2")).toBeVisible();
|
||||
await expect(testPage.getByText("T3 wf-B repo-1")).not.toBeVisible();
|
||||
await expect(taskInList(testPage, "T2 wf-A repo-2")).toBeVisible();
|
||||
await expect(taskInList(testPage, "T3 wf-B repo-1")).not.toBeVisible();
|
||||
|
||||
await selectRepositoryFilter(testPage, "E2E Repo");
|
||||
|
||||
// Workflow=seed AND repo=seed → only T1.
|
||||
await expect(testPage.getByText("T1 wf-A repo-1")).toBeVisible({
|
||||
await expect(taskInList(testPage, "T1 wf-A repo-1")).toBeVisible({
|
||||
timeout: TASK_VISIBLE_TIMEOUT,
|
||||
});
|
||||
await expect(testPage.getByText("T2 wf-A repo-2")).not.toBeVisible();
|
||||
await expect(testPage.getByText("T3 wf-B repo-1")).not.toBeVisible();
|
||||
await expect(taskInList(testPage, "T2 wf-A repo-2")).not.toBeVisible();
|
||||
await expect(taskInList(testPage, "T3 wf-B repo-1")).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,6 +11,8 @@ test.describe("Task List", () => {
|
||||
await testPage.goto("/tasks");
|
||||
await testPage.waitForLoadState("networkidle");
|
||||
|
||||
await expect(testPage.getByText("Direct Navigate Task")).toBeVisible();
|
||||
// The global AppSidebar also lists tasks (data-testid="sidebar-task-item"),
|
||||
// so a bare getByText matches two elements. Scope to the page's data table.
|
||||
await expect(testPage.getByRole("table").getByText("Direct Navigate Task")).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,9 +3,16 @@ import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { execSync } from "node:child_process";
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
import { useRegularMode } from "../../helpers/regular-mode";
|
||||
import { KanbanPage } from "../../pages/kanban-page";
|
||||
import { SessionPage } from "../../pages/session-page";
|
||||
|
||||
// Office off: the picker routes a workspace switch to `/office` only when the
|
||||
// office feature is on, and `/office` requires onboarding this regular fixture
|
||||
// doesn't perform (it errors). With office off the switch happens in place on
|
||||
// the board, exercising the same cross-workspace isolation.
|
||||
useRegularMode();
|
||||
|
||||
test.describe("Sidebar — cross-workspace isolation", () => {
|
||||
test("tasks from the previous workspace do not leak into the sidebar after switching", async ({
|
||||
testPage,
|
||||
@@ -55,13 +62,13 @@ test.describe("Sidebar — cross-workspace isolation", () => {
|
||||
await expect(kanban.taskCard(taskA.id)).toBeVisible({ timeout: 10_000 });
|
||||
await expect(kanban.taskCard(taskB.id)).not.toBeVisible();
|
||||
|
||||
// --- Switch to workspace B via the display dropdown (SPA, no full reload) ---
|
||||
await testPage.getByTestId("display-button").click();
|
||||
await testPage.getByTestId("workspace-select-trigger").click();
|
||||
await testPage.getByTestId(`workspace-select-item-${workspaceB.id}`).click();
|
||||
|
||||
// Close the dropdown so task cards are interactable.
|
||||
await testPage.keyboard.press("Escape");
|
||||
// --- Switch to workspace B via the sidebar workspace picker ---
|
||||
// The picker (top of the sidebar) is now the only workspace switcher. With
|
||||
// office off it switches in place (no /office redirect, no full reload), so
|
||||
// the board re-renders from the in-memory store with workspace B's tasks and
|
||||
// none of workspace A's.
|
||||
await testPage.getByTestId("sidebar-workspace-trigger").click();
|
||||
await testPage.getByTestId(`sidebar-workspace-item-${workspaceB.id}`).click();
|
||||
|
||||
await expect(kanban.taskCard(taskB.id)).toBeVisible({ timeout: 10_000 });
|
||||
await expect(kanban.taskCard(taskA.id)).not.toBeVisible();
|
||||
|
||||
Reference in New Issue
Block a user