Files
kandev/apps/web/e2e/pages/session-page.ts
T
José Almeida b001aa1eb8 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.
2026-06-06 21:58:39 +01:00

1341 lines
50 KiB
TypeScript

import { type Locator, type Page, expect } from "@playwright/test";
/** Maps old state-section labels to the new per-task state icon data-testid. */
function sectionLabelToStateTestId(label: string): string {
if (label === "Running") return "task-state-running";
if (label === "Turn Finished") return "task-state-review";
return "task-state-backlog";
}
export class SessionPage {
readonly chat: Locator;
readonly sidebar: Locator;
readonly terminal: Locator;
readonly files: Locator;
readonly changes: Locator;
readonly planPanel: Locator;
readonly stepper: Locator;
readonly passthroughTerminal: Locator;
constructor(private readonly page: Page) {
this.chat = page.getByTestId("session-chat");
this.sidebar = page.getByTestId("task-sidebar");
this.terminal = page.getByTestId("terminal-panel");
this.files = page.getByTestId("files-panel");
this.changes = page.getByTestId("changes-panel");
this.planPanel = page.getByTestId("plan-panel");
this.stepper = page.getByTestId("workflow-stepper");
this.passthroughTerminal = page.getByTestId("passthrough-terminal");
}
// Port forward dialog locators
get portForwardButton() {
return this.page.getByTestId("port-forward-button");
}
get portForwardDialog() {
return this.page.getByTestId("port-forward-dialog");
}
get portForwardRefresh() {
return this.page.getByTestId("port-forward-refresh");
}
get portForwardInput() {
return this.page.getByTestId("port-forward-port-input");
}
get portForwardAddButton() {
return this.page.getByTestId("port-forward-add-button");
}
portForwardRow(port: number) {
return this.page.getByTestId(`port-forward-row-${port}`);
}
// Chat status bar locators
chatStatusBar() {
return this.page.getByTestId("chat-status-bar");
}
prMergedBanner() {
return this.page.getByTestId("pr-merged-banner");
}
prMergedArchiveButton() {
return this.page.getByTestId("pr-merged-archive-button");
}
prMergedDismissButton() {
return this.page.getByTestId("pr-merged-dismiss-button");
}
prClosedBanner() {
return this.page.getByTestId("pr-closed-banner");
}
prClosedArchiveButton() {
return this.page.getByTestId("pr-closed-archive-button");
}
prClosedDismissButton() {
return this.page.getByTestId("pr-closed-dismiss-button");
}
prStatusChip() {
return this.page.getByTestId("pr-status-chip");
}
todoIndicator() {
return this.page.getByTestId("todo-indicator");
}
/** Span wrapper around the resume button — used to trigger tooltip on disabled state. */
failedSessionResumeWrapper(): Locator {
return this.page.getByTestId("failed-session-resume-wrapper");
}
/** Cancel button shown in the chat toolbar while an agent turn is running. */
cancelAgentButton(): Locator {
return this.page.getByTestId("cancel-agent-button");
}
/** The currently visible chat panel when dockview keeps background panels mounted. */
activeChat(): Locator {
return this.page.locator("[data-testid='session-chat']:visible").first();
}
async waitForLoad(timeout = 15_000) {
// When multiple session tabs are open, multiple session-chat panels exist in
// the DOM but only the active one is visible. Use :visible to avoid matching
// a hidden background panel (which would cause the wait to time out).
await this.activeChat().waitFor({ state: "visible", timeout });
}
/**
* Foreground the session chat and wait for it to be visible.
*
* After the unified AppSidebar overhaul, switching tasks via the sidebar
* restores each task's saved dockview env layout. That restored layout can
* land the chat panel as a *non-active* background tab in the right-column
* group (e.g. behind Files/Changes), so the chat is mounted but not visible
* and a plain `waitForLoad()` (which gates on `session-chat:visible`) times
* out. Clicking the session tab brings the chat to the foreground — exactly
* what a user does to read the conversation after switching tasks — and then
* we wait for the now-visible chat. No-op (still waits) when the chat is
* already foregrounded.
*/
async showSessionContext(timeout = 15_000): Promise<void> {
const tab = this.page.locator("[data-testid^='session-tab-']").first();
await tab.waitFor({ state: "visible", timeout });
// Clicking a tab that's already active is harmless; clicking a background
// one promotes its panel to the foreground.
await tab.click();
await this.activeChat().waitFor({ state: "visible", timeout });
}
/**
* Wait for the chat to be idle (input placeholder visible, agent not busy).
*
* On mobile-chrome (and occasionally desktop), there's a WS subscribe race:
* a fresh task auto-starts its agent, the mock agent completes in <1s, and
* the session_state transition (RUNNING -> AWAITING_INPUT) can fan out
* before the client's WS subscription registers server-side. The client
* then sits with `isAgentBusy=true` forever and the idle placeholder
* never renders. SSR picks up the right state on the next page load, so
* one targeted reload-and-retry is enough to recover.
*
* After a backend restart, auto-resume can briefly surface the recovery
* prompt ("Environment setup failed"); click through it when visible.
*
* This is the same race the office agent-run-live spec rides out with
* `expect.poll`-based re-seeding.
*/
async waitForChatIdle(opts: { timeout?: number; attemptTimeout?: number } = {}) {
const softTotalTimeout = opts.timeout ?? 45_000;
const attemptTimeout =
opts.attemptTimeout ?? Math.min(15_000, Math.max(5_000, Math.floor(softTotalTimeout / 3)));
const pollSlice = 1_500;
const idle = this.idleInput();
const start = Date.now();
let reloaded = false;
while (Date.now() - start < softTotalTimeout) {
if (await idle.isVisible()) return;
if (await this.recoveryResumeButton().isVisible()) {
await this.recoveryResumeButton().click();
await this.recoveryResumeButton()
.waitFor({ state: "hidden", timeout: pollSlice })
.catch(() => undefined);
continue;
}
const elapsed = Date.now() - start;
if (!reloaded && elapsed >= attemptTimeout) {
reloaded = true;
await this.page.reload();
await this.activeChat()
.waitFor({ state: "visible", timeout: attemptTimeout })
.catch(() => undefined);
continue;
}
const remaining = softTotalTimeout - elapsed;
await idle
.waitFor({ state: "visible", timeout: Math.min(pollSlice, remaining) })
.catch(() => undefined);
}
await idle.waitFor({ state: "visible", timeout: 1_000 });
}
/** Wait for the passthrough terminal to be visible (for TUI/passthrough sessions). */
async waitForPassthroughLoad(timeout = 15_000) {
await this.passthroughTerminal.waitFor({ state: "visible", timeout });
}
/** Wait for the passthrough loading indicator to be visible (scoped to agent terminal). */
async waitForPassthroughLoading(timeout = 5_000) {
await this.passthroughTerminal
.getByTestId("passthrough-loading")
.waitFor({ state: "visible", timeout });
}
/** Wait for the passthrough loading indicator to disappear (scoped to agent terminal). */
async waitForPassthroughLoaded(timeout = 15_000) {
await this.passthroughTerminal
.getByTestId("passthrough-loading")
.waitFor({ state: "hidden", timeout });
}
/**
* Read the text content of an xterm.js terminal buffer.
* xterm renders to canvas/WebGL so text isn't in the DOM. Uses the
* __xtermReadBuffer() helper exposed on the terminal container element.
*/
private readXtermBuffer(testId: string): Promise<string> {
return this.page.evaluate((tid) => {
const panel = document.querySelector(`[data-testid="${tid}"]`);
if (!panel) return "";
const xtermEl = panel.querySelector(".xterm");
type XC = HTMLElement & { __xtermReadBuffer?: () => string };
const container = xtermEl?.parentElement as XC | null | undefined;
return container?.__xtermReadBuffer?.() ?? "";
}, testId);
}
/**
* Assert the passthrough terminal buffer contains the given text.
*/
async expectPassthroughHasText(text: string, timeout = 15_000): Promise<void> {
await expect
.poll(async () => (await this.readXtermBuffer("passthrough-terminal")).includes(text), {
timeout,
message: `Expected passthrough terminal to contain "${text}"`,
})
.toBe(true);
}
/**
* Assert the passthrough terminal buffer does NOT contain the given text.
* Waits briefly to confirm absence (text could arrive asynchronously).
*/
async expectPassthroughNotHasText(text: string, stableMs = 2_000): Promise<void> {
const start = Date.now();
while (Date.now() - start < stableMs) {
if ((await this.readXtermBuffer("passthrough-terminal")).includes(text)) {
throw new Error(`Expected passthrough terminal NOT to contain "${text}", but it was found`);
}
await this.page.waitForTimeout(200);
}
}
/** Scoped to the sidebar — finds task title text rendered by TaskItem. */
taskInSidebar(title: string): Locator {
return this.sidebar.getByText(title, { exact: false });
}
sidebarTaskItem(title: string): Locator {
return this.sidebar.getByTestId("sidebar-task-item").filter({
has: this.page.getByText(title, { exact: false }),
});
}
async openSidebarTaskContextMenu(title: string): Promise<void> {
const taskRow = this.sidebarTaskItem(title).first();
await taskRow.waitFor({ state: "visible" });
await taskRow.click({ button: "right" });
}
async sendSidebarTaskToWorkflow(
title: string,
workflowId: string,
stepId: string,
): Promise<void> {
await this.openSidebarTaskContextMenu(title);
await this.page.getByTestId("task-context-send-to-workflow").hover();
await this.page.getByTestId(`task-context-workflow-${workflowId}`).hover();
await this.page.getByTestId(`task-context-step-${stepId}`).click();
}
/**
* Sidebar state indicator — returns the first icon matching the given state label.
* Accepts "Turn Finished" (review/completed), "Running" (in-progress), or "Backlog".
*/
sidebarSection(label: string): Locator {
const testId = sectionLabelToStateTestId(label);
return this.sidebar.getByTestId(testId).first();
}
/**
* Task item in the sidebar matching both a title and a state label.
* Accepts "Turn Finished" (review/completed), "Running" (in-progress), or "Backlog".
*/
taskInSection(title: string, sectionLabel: string): Locator {
const testId = sectionLabelToStateTestId(sectionLabel);
return this.sidebar
.getByTestId("sidebar-task-item")
.filter({ has: this.page.getByText(title, { exact: false }) })
.filter({ has: this.page.getByTestId(testId) });
}
/** Agent STARTING or RUNNING status indicator. */
agentStatus(): Locator {
return this.page.getByRole("status", { name: /Agent is (starting|running)/ });
}
/** Divider that appears after the "New session started" status message is rendered. */
turnComplete(): Locator {
return this.page.getByTestId("agent-turn-complete");
}
/** Chat input placeholder when agent is idle (default mode). */
idleInput(): Locator {
return this.page.locator('[data-placeholder="Continue working on the task..."]');
}
/** Chat input placeholder when agent is idle (plan mode). */
planModeInput(): Locator {
return this.page.locator('[data-placeholder="Continue working on the plan..."]');
}
/**
* "Plan mode" badge shown on a message that was sent with plan mode active.
* Appears when message.metadata.plan_mode = true, which the backend sets when
* a session is auto-started via the enable_plan_mode workflow event.
*/
planModeBadge(): Locator {
return this.chat.getByText("Plan mode", { exact: true });
}
/** Clarification overlay (visible when a clarification request is pending). */
clarificationOverlay(): Locator {
return this.page.getByTestId("clarification-overlay");
}
/** A specific clarification option button by its text label. */
clarificationOption(text: string): Locator {
return this.clarificationOverlay()
.getByTestId("clarification-option")
.filter({ hasText: text });
}
/** Skip (X) button on the clarification overlay. */
clarificationSkip(): Locator {
return this.page.getByTestId("clarification-skip");
}
/** Custom text input on the clarification overlay. */
clarificationInput(): Locator {
return this.page.getByTestId("clarification-input");
}
/** Deferred notice shown when agent has disconnected from clarification. */
clarificationDeferredNotice(): Locator {
return this.page.getByTestId("clarification-deferred-notice");
}
/** Expired notice rendered in chat history when the agent timed out waiting. */
clarificationExpiredNotice(): Locator {
return this.page.getByTestId("clarification-expired-notice");
}
/** Label span inside a clarification option. */
clarificationOptionLabels(): Locator {
return this.clarificationOverlay().getByTestId("clarification-option-label");
}
/** Description span inside a clarification option (hidden when option has none). */
clarificationOptionDescriptions(): Locator {
return this.clarificationOverlay().getByTestId("clarification-option-description");
}
/** All question cards rendered for the active clarification bundle. */
clarificationQuestionCards(): Locator {
return this.clarificationOverlay().getByTestId("clarification-question-card");
}
/** A single question card by its question id (matches metadata.question_id). */
clarificationQuestionCardById(questionId: string): Locator {
return this.clarificationOverlay().locator(
`[data-testid="clarification-question-card"][data-question-id="${questionId}"]`,
);
}
/** Group-wide progress chip "N of M answered" — only shown for bundles >1. */
clarificationGroupProgress(): Locator {
return this.clarificationOverlay().getByTestId("clarification-group-progress");
}
/** Per-question "Question N of M" progress chip. */
clarificationProgressChips(): Locator {
return this.clarificationOverlay().getByTestId("clarification-progress-chip");
}
/** Custom text input within a specific question card. */
clarificationInputForQuestion(questionId: string): Locator {
return this.clarificationQuestionCardById(questionId).getByTestId("clarification-input");
}
/** Container around the custom text input — exposes data-active for selection state. */
clarificationCustomInputContainerForQuestion(questionId: string): Locator {
return this.clarificationQuestionCardById(questionId).getByTestId("clarification-custom-input");
}
/** Option button (by visible label text) inside a specific question card. */
clarificationOptionForQuestion(questionId: string, text: string): Locator {
return this.clarificationQuestionCardById(questionId)
.getByTestId("clarification-option")
.filter({ hasText: text });
}
/** All step buttons in the horizontal stepper. */
clarificationSteps(): Locator {
return this.clarificationOverlay().getByTestId("clarification-step");
}
/** A single step in the stepper, by its 0-based index. */
clarificationStep(index: number): Locator {
return this.clarificationOverlay().locator(
`[data-testid="clarification-step"][data-step-index="${index}"]`,
);
}
/** Back button inside the carousel nav. */
clarificationPrev(): Locator {
return this.clarificationOverlay().getByTestId("clarification-prev");
}
/** Next button inside the carousel nav. */
clarificationNext(): Locator {
return this.clarificationOverlay().getByTestId("clarification-next");
}
/** Sticky "Submit" button in the overlay header (multi-question only). */
clarificationSubmit(): Locator {
return this.clarificationOverlay().getByTestId("clarification-submit");
}
/** All visible "Approve / Deny" rows for pending permission requests. */
permissionActionRows(): Locator {
return this.chat.getByTestId("permission-action-row");
}
/** All "Approve" buttons for pending permission requests. */
permissionApproveButtons(): Locator {
return this.chat.getByTestId("permission-approve");
}
/** Kandev-MCP-only "Approve" buttons (excludes the generic ToolCallMessage
* fallback row that may briefly duplicate the same pending_id). */
kandevPermissionApproveButtons(): Locator {
return this.chat.getByTestId("kandev-tool-permission").getByTestId("permission-approve");
}
/** Reset context button in the chat input toolbar. */
resetContextButton(): Locator {
return this.page.getByTestId("reset-context-button");
}
/** Confirm button in the reset context alert dialog. */
resetContextConfirm(): Locator {
return this.page.getByTestId("reset-context-confirm");
}
/** "Resume session" button shown after agent crash. */
recoveryResumeButton(): Locator {
return this.page.getByTestId("recovery-resume-button");
}
/** "Start fresh session" button shown after agent crash. */
recoveryFreshButton(): Locator {
return this.page.getByTestId("recovery-fresh-button");
}
/** "Cancel" button shown on the yellow transient-retry (529 Overloaded) card. */
recoveryCancelRetryButton(): Locator {
return this.page.getByTestId("recovery-cancel-retry-button");
}
/** The yellow "Provider overloaded — retrying…" status card text. */
transientRetryCard(): Locator {
return this.chat.getByText(/Provider overloaded — retrying/i);
}
/** Context reset divider shown in chat after resetting agent context. */
contextResetDivider(): Locator {
return this.chat.getByText("Context reset");
}
/**
* Delete a task via the sidebar context menu.
* Hovers to reveal the menu trigger, opens it, clicks "Delete",
* and confirms the delete dialog.
*/
async deleteTaskInSidebar(title: string): Promise<void> {
await this.openSidebarMenuAndClick(title, "Delete");
const confirmButton = this.page
.getByRole("alertdialog")
.getByRole("button", { name: "Delete" });
await confirmButton.click();
}
/**
* Archive a task via the sidebar context menu.
* Hovers to reveal the menu trigger, opens it, clicks "Archive",
* and confirms the archive dialog.
*/
async archiveTaskInSidebar(title: string): Promise<void> {
await this.openSidebarMenuAndClick(title, "Archive");
// Confirm the archive dialog
const confirmButton = this.page
.getByRole("alertdialog")
.getByRole("button", { name: "Archive" });
await confirmButton.click();
}
/**
* Open a sidebar task's dropdown menu and click an item.
* Retries the full open-click sequence if the menu gets detached by a
* React re-render (e.g. WS-driven sidebar update) between open and click.
*/
async openSidebarMenuAndClick(title: string, itemName: string, retries = 3): Promise<void> {
const taskRow = this.sidebar.locator('[role="button"]').filter({ hasText: title });
for (let attempt = 0; attempt < retries; attempt++) {
try {
await taskRow.hover();
await taskRow.getByRole("button", { name: "Task actions" }).click();
const menuItem = this.page.getByRole("menuitem", { name: itemName });
await menuItem.waitFor({ state: "visible", timeout: 3_000 });
await menuItem.click({ timeout: 3_000 });
return;
} catch {
// Menu was likely detached by a re-render — dismiss and retry
await this.page.keyboard.press("Escape");
await this.page.waitForTimeout(500);
}
}
// Final attempt without catch
await taskRow.hover();
await taskRow.getByRole("button", { name: "Task actions" }).click();
await this.page.getByRole("menuitem", { name: itemName }).click();
}
stepperStep(name: string): Locator {
return this.page.getByTestId(`workflow-step-${name}`);
}
/** PR button in the topbar (visible only when a PR is associated). */
prTopbarButton(): Locator {
return this.page.getByTestId("pr-topbar-button");
}
/** PR detail panel (auto-shown when task has an associated PR). */
prDetailPanel(): Locator {
return this.page.getByTestId("pr-detail-panel");
}
/** "Approve PR" button inside the PR detail panel header. Hidden when the
* current GitHub user authored the PR (self-approval is rejected upstream). */
prApproveButton(): Locator {
return this.page.getByTestId("pr-approve-button");
}
// --- PR CI accessors: desktop hover popover + chip + mobile chip drawer ---
/** The single-PR hover popover content (visible after hovering the topbar button). */
prTopbarPopover(): Locator {
return this.page.getByTestId("pr-topbar-popover");
}
/** Compact PR/CI status chip rendered in the chat status bar. */
prStatusChip(): Locator {
return this.page.getByTestId("pr-status-chip");
}
/** Mobile bottom-sheet drawer that hosts the PR CI popover. */
prStatusChipDrawer(): Locator {
return this.page.getByTestId("pr-status-chip-drawer");
}
/** Close button inside the chip's mobile drawer. */
prStatusChipDrawerClose(): Locator {
return this.page.getByTestId("pr-status-chip-drawer-close");
}
/** PRCIPopover body when rendered inside the mobile chip drawer. */
prStatusChipPopoverInner(): Locator {
return this.prStatusChipDrawer().getByTestId("pr-topbar-popover-inner");
}
/** Tap the chip and wait for the mobile drawer to be visible. */
async tapPRStatusChip(): Promise<void> {
await this.prStatusChip().tap();
await expect(this.prStatusChipDrawer()).toBeVisible({ timeout: 5_000 });
}
/** Multi-PR aggregate popover content. */
prTopbarPopoverAggregate(): Locator {
return this.page.getByTestId("pr-topbar-popover-aggregate");
}
/** A specific bucket group inside the popover by kind. */
prCheckGroup(kind: "passed" | "in_progress" | "failed"): Locator {
return this.prTopbarPopover().locator(`[data-testid='pr-check-group'][data-kind='${kind}']`);
}
/** Count number rendered inside a bucket group's header. */
prCheckGroupCount(kind: "passed" | "in_progress" | "failed"): Locator {
return this.prCheckGroup(kind).getByTestId("pr-check-group-count");
}
/** A workflow row by its workflow name (the part before " / " in CheckRun.name). */
prWorkflowRow(workflow: string): Locator {
return this.prTopbarPopover().locator(
`[data-testid='pr-workflow-row'][data-workflow='${workflow}']`,
);
}
/** Open-on-GitHub button inside a workflow row. */
prWorkflowOpenButton(workflow: string): Locator {
return this.prWorkflowRow(workflow).getByTestId("pr-workflow-open");
}
/** "+ ctx" button inside a (failed) workflow row. */
prWorkflowAddContextButton(workflow: string): Locator {
return this.prWorkflowRow(workflow).getByTestId("pr-workflow-add-context");
}
/** Review state line ("Approved 1 / 2 required" etc.). */
prReviewRow(): Locator {
return this.prTopbarPopover().getByTestId("pr-review-row");
}
/** Unresolved-comments row inside the popover. */
prCommentsRow(): Locator {
return this.prTopbarPopover().getByTestId("pr-comments-row");
}
/** Header PR-link icon (top-right corner of the popover). */
prPopoverPRLink(): Locator {
return this.prTopbarPopover().getByTestId("pr-popover-pr-link");
}
/** Header external-link icon (top-right corner of the popover). */
prPopoverExternalLink(): Locator {
return this.prTopbarPopover().getByTestId("pr-popover-external-link");
}
/** Footer "updated Ns ago" timestamp text. */
prPopoverUpdatedAt(): Locator {
return this.prTopbarPopover().getByTestId("pr-popover-updated-at");
}
/** Empty-state row when the PR has no checks yet. */
prChecksEmpty(): Locator {
return this.prTopbarPopover().getByTestId("pr-checks-empty");
}
/** "Reconnect GitHub" link rendered when auth health is unhealthy. */
prPopoverReconnectLink(): Locator {
return this.prTopbarPopover().getByTestId("pr-popover-reconnect-link");
}
/**
* Open the popover by hovering the topbar button. Waits for the open delay
* (~150ms in PRTopbarButton) plus a small buffer.
*
* To keep the popover open while interacting with rows, the test should
* hover the popover content directly afterwards (Playwright hover() over a
* row inside the popover keeps the cursor in the open region).
*/
async hoverPRTopbar(): Promise<void> {
await this.prTopbarButton().hover();
await expect(this.prTopbarPopover()).toBeVisible({ timeout: 5_000 });
}
/**
* Assert the `pr-detail` panel's dockview group contains at least one
* `session:{sessionId}` panel — i.e. the PR opened as a tab next to a
* session chat, not as a split in a separate group. Regression guard for
* the "PR opens in a split instead of the center tab" bug.
*
* Checks group membership of the PR panel directly rather than picking a
* session panel first, so the assertion is deterministic even when outgoing
* and incoming session panels briefly coexist during a task switch.
*/
async expectPrPanelAndSessionShareGroup(): Promise<void> {
const result = await this.page.evaluate(() => {
type Panel = { id: string; group?: { id?: string } };
type Api = { panels: Panel[]; getPanel: (i: string) => Panel | undefined };
const api = (window as unknown as { __dockviewApi__?: Api }).__dockviewApi__;
if (!api) return { error: "dockview api not exposed" };
const pr = api.getPanel("pr-detail");
if (!pr) return { error: "pr-detail panel missing" };
const prGroupId = pr.group?.id ?? null;
const sessionPanels = api.panels.filter((p) => p.id.startsWith("session:"));
if (sessionPanels.length === 0) return { error: "no session panel" };
const sessionInPrGroup = sessionPanels.some((p) => p.group?.id === prGroupId);
return {
sessionInPrGroup,
prGroupId,
sessionLocations: sessionPanels.map((p) => `${p.id}@${p.group?.id ?? "?"}`),
};
});
expect(result.error, result.error).toBeUndefined();
expect(
result.sessionInPrGroup,
`PR panel landed in a dockview group that contains no session chat. ` +
`PR group=${result.prGroupId} sessions=[${result.sessionLocations?.join(", ")}]`,
).toBe(true);
}
/**
* Like `expectPrPanelAndSessionShareGroup`, but matches any pr-detail panel
* (legacy `pr-detail` or keyed `pr-detail|owner/repo/N`). Use for flows that
* exercise the manual click path, which always creates a keyed panel.
*/
async expectAnyPrPanelAndSessionShareGroup(): Promise<void> {
const result = await this.page.evaluate(() => {
type Panel = { id: string; group?: { id?: string } };
type Api = { panels: Panel[]; getPanel: (i: string) => Panel | undefined };
const api = (window as unknown as { __dockviewApi__?: Api }).__dockviewApi__;
if (!api) return { error: "dockview api not exposed" };
const prPanels = api.panels.filter(
(p) => p.id === "pr-detail" || p.id.startsWith("pr-detail|"),
);
if (prPanels.length === 0) return { error: "no pr-detail panel" };
const sessionPanels = api.panels.filter((p) => p.id.startsWith("session:"));
if (sessionPanels.length === 0) return { error: "no session panel" };
const sessionGroupIds = new Set(sessionPanels.map((p) => p.group?.id).filter(Boolean));
const allPrsInSessionGroup = prPanels.every((p) => {
const gid = p.group?.id;
return gid !== undefined && sessionGroupIds.has(gid);
});
return {
allPrsInSessionGroup,
prLocations: prPanels.map((p) => `${p.id}@${p.group?.id ?? "?"}`),
sessionLocations: sessionPanels.map((p) => `${p.id}@${p.group?.id ?? "?"}`),
};
});
expect(result.error, result.error).toBeUndefined();
expect(
result.allPrsInSessionGroup,
`PR panel(s) landed outside the session's group. ` +
`prs=[${result.prLocations?.join(", ")}] sessions=[${result.sessionLocations?.join(", ")}]`,
).toBe(true);
}
/** Dockview tab for the PR detail panel (title starts as "Pull Request", updated to "PR #N"). */
prDetailTab(): Locator {
return this.page.locator(".dv-default-tab").filter({ hasText: /^(Pull Request|PR #\d+)$/ });
}
/** Click a dockview tab by its visible label (e.g. "Changes", "Files", "Terminal"). */
async clickTab(label: string): Promise<void> {
const tab = this.page.locator(`.dv-default-tab:has-text('${label}')`);
await tab.click();
}
/**
* Click the session/chat tab regardless of its current title.
* Session tabs are renamed from "Agent" to "#N AgentName" by useChatSessionTitle,
* so this uses the stable data-testid on the ContextMenuTrigger instead.
*/
async clickSessionChatTab(): Promise<void> {
await this.page.locator('[data-testid^="session-tab-"]').first().click();
}
/** PR files section within the changes panel. */
prFilesSection(): Locator {
return this.changes.getByTestId("pr-files-section");
}
/** Commits section within the changes panel (unified list of pushed + unpushed commits). */
commitsSection(): Locator {
return this.changes.getByTestId("commits-section");
}
/** Expand a collapsible section in the changes panel if currently collapsed. */
async expandChangesSection(testId: string): Promise<void> {
const toggle = this.changes.getByTestId(`${testId}-collapse-toggle`);
await expect(toggle).toBeVisible({ timeout: 15_000 });
if ((await toggle.getAttribute("aria-expanded")) === "false") {
await toggle.click();
await expect(toggle).toHaveAttribute("aria-expanded", "true");
}
}
/** Expand the commits section (collapsed by default in the changes panel). */
async expandCommitsSection(): Promise<void> {
await this.expandChangesSection("commits-section");
}
/** Expand the PR Changes section (collapsed by default in the changes panel). */
async expandPRChangesSection(): Promise<void> {
await this.expandChangesSection("pr-changes-section");
}
/**
* Types a message into the TipTap chat input and sends it.
* Default submit key is Cmd+Enter (chatSubmitKey = "cmd_enter").
* TipTap maps "Mod" to Meta on macOS and Control on Linux/Windows.
*/
async sendMessage(text: string) {
const editor = this.page.locator(".tiptap.ProseMirror").first();
await editor.click();
await editor.fill(text);
const modifier = process.platform === "darwin" ? "Meta" : "Control";
await editor.press(`${modifier}+Enter`);
}
/**
* Type and submit a chat message via the Send button. Mobile (touch) layouts
* don't submit on Ctrl/Cmd+Enter, so mobile specs use this instead.
*/
async sendMessageViaButton(text: string) {
const editor = this.page.locator(".tiptap.ProseMirror").first();
await editor.click();
await editor.fill(text);
await this.page.getByTestId("submit-message-button").click();
}
/** Toggle plan mode on/off by clicking the plan mode toggle button in the toolbar.
*
* Waits for the button to advertise `data-plan-available="true"` before clicking.
* Without this gate the click can fire before `useSessionMcp` has resolved
* `supports_mcp` for the session's agent profile (e.g. the agent type data
* hasn't propagated into `settingsAgents.items` yet). The button is always
* rendered, but with `planModeAvailable=false` the click only toggles the
* plan layout — it does NOT enable plan mode on the chat input, so the
* downstream `planModeInput()` assertion would time out for a race rather
* than a real bug.
*/
async togglePlanMode() {
const btn = this.page.getByTestId("plan-mode-toggle-button");
await expect(btn).toBeVisible({ timeout: 10_000 });
await expect(btn).toHaveAttribute("data-plan-available", "true", { timeout: 10_000 });
await btn.click();
}
/**
* Wait until the shell terminal panel's "Connecting terminal..." overlay
* disappears — i.e. the WebSocket actually opened for that env terminal.
* Use this to detect the "terminal hangs forever on Connecting" bug.
*/
async expectTerminalConnected(timeout = 15_000): Promise<void> {
await this.terminal.getByTestId("passthrough-loading").waitFor({ state: "hidden", timeout });
}
/**
* Wait for the terminal shell to be connected (buffer has content from
* the prompt), then type a command and press Enter.
*/
async typeInTerminal(command: string): Promise<void> {
await expect
.poll(async () => (await this.readXtermBuffer("terminal-panel")).length > 0, {
timeout: 15_000,
message: "Waiting for terminal shell to connect",
})
.toBe(true);
const xterm = this.terminal.locator(".xterm");
await xterm.click();
await this.page.keyboard.type(command);
await this.page.keyboard.press("Enter");
}
/**
* Assert the terminal buffer contains the given text.
*/
async expectTerminalHasText(text: string): Promise<void> {
await expect
.poll(async () => (await this.readXtermBuffer("terminal-panel")).includes(text), {
timeout: 10_000,
message: `Expected terminal to contain "${text}"`,
})
.toBe(true);
}
/**
* Click the maximize button on the dockview group that contains a tab
* with the given name. Defaults to "Terminal".
*/
async clickMaximize(tabName = "Terminal"): Promise<void> {
const header = this.page.locator(
`.dv-tabs-and-actions-container:has(.dv-default-tab:has-text('${tabName}'))`,
);
await header.getByTestId("dockview-maximize-btn").click();
}
/**
* Assert the layout is in maximized state: terminal visible,
* sidebar visible (UI: |sidebar|maximized-group|), chat and files hidden.
*/
async expectMaximized(): Promise<void> {
await expect(this.terminal).toBeVisible({ timeout: 10_000 });
await expect(this.sidebar).toBeVisible();
await expect(this.chat).not.toBeVisible({ timeout: 5_000 });
await expect(this.files).not.toBeVisible({ timeout: 5_000 });
}
/**
* Assert the layout is in the default (non-maximized) state:
* chat, terminal, files, and sidebar are all visible, and layout fills the viewport.
*/
async expectDefaultLayout(): Promise<void> {
await expect(this.chat).toBeVisible({ timeout: 10_000 });
await expect(this.terminal).toBeVisible({ timeout: 10_000 });
await expect(this.files).toBeVisible({ timeout: 10_000 });
await expect(this.sidebar).toBeVisible();
await this.expectNoLayoutGap();
}
/**
* Wait until the dockview api is exposed on `window` and reports at least
* one group with a positive width. Use this as a layout-ready gate for tests
* that assert on layout state but don't need the agent to be idle (the
* agent may keep cycling Starting → idle → Starting under workflow
* auto-start, never settling within a single polling window).
*/
async waitForDockviewReady(timeout = 15_000): Promise<void> {
await expect
.poll(
async () => {
return this.page.evaluate(() => {
type Group = { id: string; width: number };
type Api = { groups: Group[] };
const api = (window as unknown as { __dockviewApi__?: Api }).__dockviewApi__;
if (!api) return false;
return api.groups.some((g) => g.width > 1);
});
},
{ timeout, message: "Waiting for dockview api with positive-width groups" },
)
.toBe(true);
}
/**
* Assert the live dockview groups all have positive widths and that the sum
* of the root-level column widths is approximately equal to the api width.
* Catches "central group has zero/wrong width" corruption that persists
* across task switches when a corrupted layout is saved to per-session storage.
*/
async expectLayoutHealthy(): Promise<void> {
const result = await this.page.evaluate(() => {
type Group = { id: string; width: number; height: number };
type Api = { width: number; height: number; groups: Group[] };
const api = (window as unknown as { __dockviewApi__?: Api }).__dockviewApi__;
if (!api) return { error: "dockview api not exposed" };
const bad = api.groups.filter((g) => !(g.width > 1));
const totalWidth = api.groups.reduce((s, g) => s + (g.width > 0 ? g.width : 0), 0);
return {
apiWidth: api.width,
groups: api.groups.map((g) => ({ id: g.id, width: g.width })),
badCount: bad.length,
totalWidth,
};
});
expect(result.error, result.error).toBeUndefined();
expect(
result.badCount,
`Found ${result.badCount} dockview groups with width <= 1: ${JSON.stringify(result.groups)}`,
).toBe(0);
// Sum of group widths should match api width within a small rounding tolerance.
// Note: groups can be stacked vertically so totalWidth may exceed apiWidth (one column,
// multiple groups) — only flag if totalWidth is much smaller than apiWidth (squished).
expect(
result.totalWidth! >= (result.apiWidth ?? 0) - 4,
`Total group widths (${result.totalWidth}) much smaller than api width (${result.apiWidth}): ${JSON.stringify(result.groups)}`,
).toBe(true);
}
/**
* Assert the dockview layout columns fill the container with no large empty gap.
* Catches bugs where columns don't expand after api.fromJSON() + setConstraints
* (e.g. missing api.layout() call).
*/
async expectNoLayoutGap(maxGapPx = 20): Promise<void> {
await expect
.poll(
async () => {
return this.page.evaluate((maxGap: number) => {
const dv = document.querySelector(".dv-dockview");
if (!dv) return false;
const dvRect = dv.getBoundingClientRect();
// Find the rightmost edge among all top-level column views
const views = dv.querySelectorAll(
".dv-split-view-container.dv-horizontal > .dv-view-container > .dv-view",
);
if (views.length === 0) return false;
let maxRight = 0;
for (const v of views) {
const r = v.getBoundingClientRect();
if (r.width > 0) maxRight = Math.max(maxRight, r.right);
}
return dvRect.right - maxRight <= maxGap;
}, maxGapPx);
},
{ timeout: 5_000, message: "Layout has an empty gap on the right side (squished layout)" },
)
.toBe(true);
}
/** Git operation error message in chat (shown when a git operation fails). */
gitOperationErrorMessage(): Locator {
return this.chat.locator("div:has([data-testid='git-fix-button'])").first();
}
/** Fix button on a git operation error message. */
gitFixButton(): Locator {
return this.chat.getByTestId("git-fix-button");
}
/** Locator for the VS Code dockview tab. */
vscodeTab(): Locator {
return this.page.locator(".dv-default-tab:has-text('VS Code')");
}
/** Locator for the VS Code code-server iframe. */
vscodeIframe(): Locator {
return this.page.locator('iframe[title="VS Code"]');
}
// --- New Session Dialog ---
/** "+" button in the dockview header to open the add-panel dropdown. */
addPanelButton(): Locator {
return this.page.getByTestId("dockview-add-panel-btn").first();
}
/** "New Session" menu item in the dockview + dropdown. */
newSessionMenuButton(): Locator {
return this.page.getByTestId("new-session-button");
}
/** Open the new session dialog via the + menu. */
async openNewSessionDialog(): Promise<void> {
await this.addPanelButton().click();
await this.newSessionMenuButton().click();
}
/** The new session dialog container. */
newSessionDialog(): Locator {
return this.page.getByRole("dialog").filter({ hasText: "New agent in" });
}
/** Prompt textarea inside the new session dialog. */
newSessionPromptInput(): Locator {
return this.newSessionDialog().locator("textarea");
}
/** Start Agent button inside the new session dialog. */
newSessionStartButton(): Locator {
return this.newSessionDialog().getByRole("button", { name: "Start Agent" });
}
/** Environment info badges inside the new session dialog. */
newSessionEnvironmentInfo(): Locator {
return this.newSessionDialog().getByText("Same environment as current session");
}
/** Session tab in dockview by session label (e.g., "Session 1", "Session 2"). */
sessionTab(label: string): Locator {
return this.page.locator(`.dv-default-tab:has-text('${label}')`);
}
/** Session item in the + dropdown's reopen list by session ID. */
sessionReopenItem(sessionId: string): Locator {
return this.page.getByTestId(`reopen-session-${sessionId}`);
}
/** All session reopen items in the + dropdown. */
sessionReopenItems(): Locator {
return this.page.locator("[role='menuitem'][data-testid^='reopen-session-']");
}
/** All session tabs in dockview (panels using the sessionTab tab component). */
sessionTabs(): Locator {
return this.page.locator(".dv-default-tab").filter({
has: this.page.locator("[data-testid^='reopen-session-'], .tabler-icon-star").first(),
});
}
/** Dockview session tab matched by partial text (e.g., "Mock Agent" or index "1"). */
sessionTabByText(text: string): Locator {
return this.page.locator(`[data-testid^='session-tab-']:has-text('${text}')`);
}
/** Session tab container identified by session ID (data-testid="session-tab-{id}"). */
sessionTabBySessionId(sessionId: string): Locator {
return this.page.getByTestId(`session-tab-${sessionId}`);
}
/** Dockview close (X) button inside a session tab. */
sessionTabCloseButton(sessionId: string): Locator {
return this.page.getByTestId(`session-tab-close-${sessionId}`);
}
/** Context menu on a dockview tab — right-click the tab to trigger it. */
async rightClickTab(text: string): Promise<void> {
const tab = this.page.locator(`[data-testid^='session-tab-']:has-text('${text}')`);
await tab.click({ button: "right" });
}
/** Right-click the first session tab (useful when there is only one session). */
async rightClickFirstSessionTab(): Promise<void> {
const tab = this.page.locator("[data-testid^='session-tab-']").first();
await tab.click({ button: "right" });
}
/** Context menu item by visible label. */
contextMenuItem(label: string): Locator {
return this.page.getByRole("menuitem", { name: label });
}
/** Alert dialog (e.g., delete confirmation). */
alertDialog(): Locator {
return this.page.getByRole("alertdialog");
}
/** Primary star icon inside a dockview session tab. The star is rendered as a
* sibling of `.dv-default-tab` inside the `data-testid="session-tab-<id>"`
* wrapper, so we anchor on that wrapper rather than `.dv-default-tab` itself. */
primaryStarInTab(text: string): Locator {
return this.sessionTabByText(text).locator(".tabler-icon-star").first();
}
/** "Move to next step" button in the chat status bar. */
proceedNextStepButton(): Locator {
return this.page.getByTestId("proceed-next-step");
}
/** Click a task in the sidebar by title. */
async clickTaskInSidebar(title: string): Promise<void> {
const taskRow = this.sidebar.locator("[role='button']").filter({ hasText: title });
await taskRow.click();
}
// --- File tree multi-select helpers ---
/** Find a tree node by its data-path attribute. */
fileTreeNode(nodePath: string): Locator {
return this.files.locator(`[data-testid="file-tree-node"][data-path="${nodePath}"]`);
}
/** All file tree nodes with data-selected="true". */
fileTreeSelectedNodes(): Locator {
return this.files.locator("[data-selected='true']");
}
// --- Changes panel multi-select helpers ---
/** Find a file row in the changes panel by path. */
changesFileRow(path: string): Locator {
return this.changes.locator(`[data-changes-file="${path}"]`);
}
/** All selected file rows in the changes panel. */
changesSelectedRows(): Locator {
return this.changes.locator("[data-selected='true']");
}
/** All file rows in the changes panel currently marked as the active tab. */
changesActiveRows(): Locator {
return this.changes.locator("[data-active='true']");
}
/**
* Close every file-diff panel in dockview: the `preview:file-diff` slot AND
* any pinned `diff:file:<path>` panels created by promoting the preview.
* After this resolves, no diff tab is active so the changes-panel rows
* settle to `data-active="false"`.
*/
async closeFileDiffPreview(): Promise<void> {
await this.page.evaluate(() => {
type PanelApi = { close: () => void };
type Panel = { id: string; api: PanelApi };
type Api = { panels: Panel[]; getPanel: (i: string) => Panel | undefined };
const api = (window as unknown as { __dockviewApi__?: Api }).__dockviewApi__;
if (!api) return;
api.getPanel("preview:file-diff")?.api.close();
// Snapshot before iterating: panel.api.close() mutates api.panels in
// place, so iterating the live array would skip every other panel.
const pinned = [...api.panels].filter((p) => p.id.startsWith("diff:file:"));
for (const panel of pinned) panel.api.close();
});
}
/** Bulk action bar for a variant (unstaged/staged). */
changesBulkActionBar(variant: "unstaged" | "staged"): Locator {
return this.changes.getByTestId(`bulk-actions-${variant}`);
}
/** Bulk stage button (unstaged section). */
changesBulkStageButton(): Locator {
return this.changes.getByTestId("bulk-stage");
}
/** Bulk unstage button (staged section). */
changesBulkUnstageButton(): Locator {
return this.changes.getByTestId("bulk-unstage-staged");
}
/** Bulk discard button for a variant. */
changesBulkDiscardButton(variant: "unstaged" | "staged" = "unstaged"): Locator {
return this.changes.getByTestId(`bulk-discard-${variant}`);
}
// --- Plan revisions / rewind ---
/** Rewind button in the plan panel header (opens revision history popover). */
rewindButton(): Locator {
return this.planPanel.getByTestId("plan-rewind-button");
}
/** Plan revisions popover (opens after clicking rewind). */
revisionsPopover(): Locator {
return this.page.getByTestId("plan-revisions-popover");
}
/** All revision rows inside the popover, newest-first. */
revisionRows(): Locator {
return this.revisionsPopover().getByTestId("plan-revision-row");
}
/** Specific revision row by number. */
revisionRow(n: number): Locator {
return this.revisionsPopover().locator(`[data-revision-number="${n}"]`);
}
/** Revert button scoped to a given revision row. */
revertButton(row: Locator): Locator {
return row.getByTestId("plan-revision-revert-button");
}
/** Revert-confirm dialog. */
revertConfirmDialog(): Locator {
return this.page.getByTestId("plan-revert-confirm-dialog");
}
revertConfirmOk(): Locator {
return this.page.getByTestId("plan-revert-confirm-ok");
}
revertConfirmCancel(): Locator {
return this.page.getByTestId("plan-revert-confirm-cancel");
}
/** TipTap editor inside the plan panel (for typing user edits). */
planEditor(): Locator {
return this.planPanel.locator(".ProseMirror");
}
/** Open the rewind popover and wait for it to render. No-op when already open. */
async openRewind(): Promise<void> {
if (await this.revisionsPopover().isVisible()) return;
await this.rewindButton().click();
await expect(this.revisionsPopover()).toBeVisible({ timeout: 5_000 });
}
/** Open rewind, click revert on the row with the given revision number, and confirm. */
async revertToRevision(n: number): Promise<void> {
await this.openRewind();
await this.revertButton(this.revisionRow(n)).click();
await expect(this.revertConfirmDialog()).toBeVisible({ timeout: 5_000 });
await this.revertConfirmOk().click();
}
// --- Plan revision preview & compare (Phase 6) ---
/** Click the row body (not the Revert/Compare buttons) to open the preview dialog. */
revisionRowBody(row: Locator): Locator {
return row.getByTestId("plan-revision-row-body");
}
previewDialog(): Locator {
return this.page.getByTestId("plan-revision-preview-dialog");
}
previewBody(): Locator {
return this.page.getByTestId("plan-revision-preview-body");
}
previewRestoreButton(): Locator {
return this.page.getByTestId("plan-revision-preview-restore");
}
previewCompareWithCurrentButton(): Locator {
return this.page.getByTestId("plan-revision-preview-compare-with-current");
}
previewCompareWithPreviousButton(): Locator {
return this.page.getByTestId("plan-revision-preview-compare-with-previous");
}
previewCloseButton(): Locator {
return this.page.getByTestId("plan-revision-preview-close");
}
diffDialog(): Locator {
return this.page.getByTestId("plan-revision-diff-dialog");
}
diffSummary(): Locator {
return this.page.getByTestId("plan-revision-diff-summary");
}
diffLines(kind?: "add" | "remove" | "context"): Locator {
const root = this.diffDialog();
if (!kind) return root.getByTestId("plan-revision-diff-line");
return root.locator(`[data-testid="plan-revision-diff-line"][data-line-kind="${kind}"]`);
}
diffSplitCells(kind?: "add" | "remove" | "context" | "empty"): Locator {
const root = this.diffDialog();
if (!kind) return root.getByTestId("plan-revision-diff-split-cell");
return root.locator(`[data-testid="plan-revision-diff-split-cell"][data-line-kind="${kind}"]`);
}
diffModeToggle(mode: "unified" | "split"): Locator {
return this.page.getByTestId(`plan-revision-diff-mode-${mode}`);
}
diffRestoreButton(): Locator {
return this.page.getByTestId("plan-revision-diff-restore");
}
diffCloseButton(): Locator {
return this.page.getByTestId("plan-revision-diff-close");
}
/** Open rewind and click into the row body to bring up the preview dialog. */
async openRevisionPreview(n: number): Promise<void> {
await this.openRewind();
await this.revisionRowBody(this.revisionRow(n)).click();
await expect(this.previewDialog()).toBeVisible({ timeout: 5_000 });
}
// --- Panel search helpers (Ctrl+F feature) ---
/** Any currently-mounted panel search bar. */
panelSearchBar(): Locator {
return this.page.locator("[data-panel-search-bar]");
}
/** Search input inside the currently-mounted bar. */
panelSearchInput(): Locator {
return this.panelSearchBar().locator('input[type="text"]');
}
/** "N / M" match counter. */
panelSearchCounter(): Locator {
return this.panelSearchBar().locator('[aria-live="polite"]');
}
}