Files
kandev/apps/web/e2e/pages/session-page.ts
T
José Almeida 2b8513f902 feat: add multi-select and drag-to-move for file tree and changes panel (#490)
* feat: add multi-select and drag-to-move for file tree and changes panel

Add Shift-click range and Ctrl/Cmd-click toggle selection to the file
tree and staged/unstaged changes sections. Includes bulk actions
(delete, stage, unstage, discard), drag-and-drop file moving into
directories, keyboard shortcuts (Escape, Ctrl+A), and E2E tests.

* fix: move bulk action components to file-row to fix max-lines lint

* style: apply prettier formatting to all changed files

* fix: bulk delete all selected items and update anchor on stale shift-click

* fix: address CodeRabbit review comments

- Keep original file-row-* data-testid for backwards compat
- Remove eager onClearSelection from bulk actions (auto-pruned by hook)
- Variant-scope bulk-discard/bulk-unstage test ids
- Remove index.lock deletion from E2E GitHelper retry loop

* fix: fix E2E tests — create files after session open, remove DnD test

- Create files AFTER opening session so WS detects git status changes
- Add proper git env vars (GIT_AUTHOR_NAME, etc.) matching existing tests
- Use element.press("Escape") instead of page keyboard for proper focus
- Remove drag-to-directory test (Playwright dragTo doesn't support HTML5
  dataTransfer events needed by our DnD implementation)

* fix: rename E2E task titles to avoid ambiguous 'Changes' tab selector

* fix: make file list focusable for Escape key handler in E2E tests

* fix: improve selection styling and ignore right-clicks in selection handler

- Use text-accent-foreground on selected rows for dark mode readability
- Override child span colors with [&_span]:text-accent-foreground
- Use hover:bg-accent/80 instead of hover:bg-muted on selected items
- Skip selection handling on right-click (button === 2) so context menu
  works without clearing multi-selection

* fix: plain click should not select — only Ctrl/Cmd+click and Shift+click

Plain click now clears any selection and performs the original action
(open file / toggle folder / open diff). Selection only happens with
modifier keys. handleClick returns boolean to indicate whether the
click was consumed by selection logic.

* fix: fix multi-select bugs and expand E2E test coverage

Bug fixes:
- Change isSelected from boolean to function prop so children compute
  their own selection state (fixes nested file selection)
- Inline TreeNodeRow JSX into TreeNodeItem so ContextMenuTrigger asChild
  can attach refs directly (fixes right-click context menu)
- Set lastClickedRef on plain click so shift-click has a valid anchor
- Add click-outside handler to clear selection

E2E tests expanded to 14 file-tree tests + 3 changes-panel tests:
- Ctrl-click select, toggle, multi-select
- Plain click clears selection
- Shift-click range after plain click and ctrl-click anchors
- Directory selection and nested file selection
- Right-click context menu (single + bulk delete)
- Click outside clears selection
- Escape clears selection

* fix: move AlertDialog outside ContextMenu to fix delete action

The AlertDialog wrapping the ContextMenu caused Radix focus management
to intercept context menu item selection, making Delete trigger Rename.
Moving AlertDialog to a sibling of ContextMenu fixes the conflict.

* fix: reorder context menu — Delete first, Rename second

Radix ContextMenu auto-focuses the first item on open, which caused
accidental Rename triggers. Putting Delete first (the more common
action for selection) avoids this.

* fix: don't clear selection when clicking context menu or dialog

The click-outside handler was clearing selection when clicking on
context menu items or confirmation dialog buttons (rendered in portals
outside the tree container). Now skips clearing for elements with
role=menu, role=alertdialog, or role=dialog.

Also extended the bulk delete E2E test to verify both files are
actually removed after confirming the dialog.

* fix: hide file count in delete dialog when folder children not loaded

When a folder hasn't been expanded, its children aren't loaded yet so
countFilesInTree returns 0. Instead of showing "0 files", hide the
count entirely and just show "delete <folder name>".

* fix: show confirmation dialog for bulk discard in changes panel

Bulk discard was calling git.discard() directly, bypassing the
confirmation dialog. Now routes through handleBulkDiscardClick which
opens the DiscardDialog with a bulk message ("discard changes to N
files") before executing.

* fix: address remaining CodeRabbit review comments

- Deduplicate filenames on move: when moving files with identical names
  to the same directory, append "(1)", "(2)" etc. to avoid collisions.
  Both tree update and rename API calls use the same deduplication.
- Replace void operator with .catch(() => {}) for bulk stage/unstage
  to prevent unhandled promise rejections.
- Add document-level dragend listener as safety net to clear stuck drag
  state if the element's onDragEnd doesn't fire (Escape, out-of-window).

* test: add unit tests for file tree move deduplication and visible paths

* fix: address all remaining PR review comments

- Move onSelectionChange out of React state updater (side effect fix)
- Stabilize keyboard listener deps (pass callbacks not object)
- Remove unnecessary useMemo in BulkActionBar
- Fix ghost-restore on partial bulk delete (remove all at once)
- Fix blank filename on single-file bulk discard
- Add aria-selected and role=treeitem for accessibility
- Extract computeNextSelection params into object (max-params lint)

* fix: add missing bulk action props to WorkingTreeProps type

WorkingTreeSections referenced onBulkStage, onBulkUnstage, and
onBulkDiscard but WorkingTreeProps Pick didn't include them, causing
a TypeScript build failure.

* fix: address final review comments

- Add unit tests for computeNextSelection (8 tests covering ctrl-click,
  shift-click range, stale anchor, reverse range, shift+ctrl extend)
- Extract shared E2E helpers (GitHelper, makeGitEnv, openTaskSession,
  createStandardProfile) to e2e/helpers/git-helper.ts
- Fix misleading comment in bulk delete (says "re-insert only failed"
  but implementation restores full snapshot)

* fix: use Promise.all for bulk delete to prevent partial rollback

Individual .catch() handlers could restore already-deleted files
if a later delete failed. Now uses Promise.all so rollback only
happens if any delete rejects.

* test: add renderHook tests for useMultiSelect hook behaviors

- Stale-path pruning: items shrink → selected paths auto-pruned
- Plain click: returns false, clears existing selection
- selectAll/clearSelection: work correctly, fire onSelectionChange
- Install @testing-library/react for renderHook support

* fix: handle false returns from onDeleteFile in bulk delete Promise.all

* chore: remove unused @testing-library/react-hooks dependency
2026-04-11 17:43:21 +01:00

624 lines
22 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");
}
todoIndicator() {
return this.page.getByTestId("todo-indicator");
}
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.page
.locator("[data-testid='session-chat']:visible")
.first()
.waitFor({ state: "visible", timeout });
}
/** 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 });
}
/**
* 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");
}
/** 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");
}
/** 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, and clicks "Delete".
*/
async deleteTaskInSidebar(title: string): Promise<void> {
await this.openSidebarMenuAndClick(title, "Delete");
}
/**
* Archive a task via the sidebar context menu.
* Hovers to reveal the menu trigger, opens it, and clicks "Archive".
*/
async archiveTaskInSidebar(title: string): Promise<void> {
await this.openSidebarMenuAndClick(title, "Archive");
}
/**
* 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.
*/
private 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");
}
/** VCS split button primary action. Pass action to match a specific state. */
vcsPrimaryButton(action?: "commit" | "push" | "pr" | "rebase"): Locator {
if (action) {
return this.page.getByTestId(`vcs-primary-${action}`);
}
return this.page.locator('[data-testid^="vcs-primary-"]');
}
/** PR detail panel (auto-shown when task has an associated PR). */
prDetailPanel(): Locator {
return this.page.getByTestId("pr-detail-panel");
}
/** 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");
}
/**
* 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`);
}
/** Toggle plan mode on/off by clicking the plan mode toggle button in the toolbar. */
async togglePlanMode() {
const btn = this.page.getByTestId("plan-mode-toggle-button");
await expect(btn).toBeVisible({ timeout: 10_000 });
await btn.click();
}
/**
* 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();
}
/**
* 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("[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}`);
}
/** 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" });
}
/** 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 tab. */
primaryStarInTab(text: string): Locator {
const tab = this.page.locator(`.dv-default-tab:has-text('${text}')`);
return tab.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']");
}
/** 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}`);
}
}