Files
kandev/apps/web/e2e/tests/task/recent-task-switcher.spec.ts
T
José Almeida b0b968cb22 feat(web): add reverse direction to recent task switcher (#1241)
* feat(web): add reverse direction to recent task switcher

Cmd/Ctrl+Shift+Space now cycles the recent task switcher backward
(most-recent toward oldest), complementing the forward Cmd/Ctrl+Space.
Both directions are user-configurable via Settings > Keyboard Shortcuts.

- new TASK_SWITCHER_REVERSE shortcut; Shift keeps it mutually exclusive
  with the forward binding so both can be held together
- thread a cycle direction through openSwitcher/cycleSwitcher; reverse
  opens on the oldest non-current task
- commit on release of either binding's hold modifier
- switcher footer shows both Next / Previous bindings

* fix(web): commit task switcher only on the active binding's modifier

Address PR review on the reverse task switcher:

- track the binding that drove the switcher and commit only on ITS hold
  modifier release, so divergent custom forward/reverse bindings can't
  commit early (greptile/coderabbit)
- move the CycleDirection type below the import block
- correct the reverse-shortcut direction comment (oldest -> most-recent)
- assert exact task titles in the reverse e2e precondition

* test(web): assert task switcher stays open on Shift-release

Make explicit that releasing the secondary modifier alone does not commit
the reverse switcher — only the hold modifier does.
2026-06-02 13:44:17 +01:00

171 lines
6.5 KiB
TypeScript

import { test, expect } from "../../fixtures/test-base";
function taskSwitcherModifier() {
return process.platform === "darwin" ? "Meta" : "Control";
}
test.describe("Recent task switcher", () => {
test("default shortcut stays open while held and switches on modifier release", async ({
testPage,
apiClient,
seedData,
}) => {
const first = await apiClient.createTask(seedData.workspaceId, "Recent Switcher Alpha", {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
});
const second = await apiClient.createTask(seedData.workspaceId, "Recent Switcher Beta", {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
});
await testPage.goto(`/t/${first.id}`);
await expect(testPage.getByText("Recent Switcher Alpha").first()).toBeVisible({
timeout: 15_000,
});
await testPage.goto(`/t/${second.id}`);
await expect(testPage.getByText("Recent Switcher Beta").first()).toBeVisible({
timeout: 15_000,
});
const modifier = taskSwitcherModifier();
await testPage.keyboard.down(modifier);
await testPage.keyboard.press("Space");
const switcher = testPage.getByTestId("recent-task-switcher");
await expect(switcher).toBeVisible({ timeout: 5_000 });
await expect(switcher.getByText("Recent Switcher Beta")).toBeVisible();
await expect(switcher.getByText("Recent Switcher Alpha")).toBeVisible();
await expect(switcher.getByTestId("recent-task-switcher-badge-status").first()).toBeVisible();
await expect(
switcher
.getByTestId("recent-task-switcher-badge-repository")
.filter({ hasText: "E2E Repo" })
.first(),
).toBeVisible();
await expect(
switcher
.getByTestId("recent-task-switcher-badge-workflow")
.filter({ hasText: "E2E Workflow" })
.first(),
).toBeVisible();
const previousRow = switcher.getByTestId(`recent-task-switcher-item-${first.id}`);
const currentRow = switcher.getByTestId(`recent-task-switcher-item-${second.id}`);
await expect(previousRow).toHaveAttribute("data-selected", "true");
await testPage.keyboard.press("Space");
await expect(currentRow).toHaveAttribute("data-selected", "true");
await testPage.keyboard.press("Space");
await expect(previousRow).toHaveAttribute("data-selected", "true");
await testPage.keyboard.up(modifier);
await expect(switcher).not.toBeVisible({ timeout: 5_000 });
await expect(testPage).toHaveURL(new RegExp(`/t/${first.id}`), { timeout: 10_000 });
});
test("reverse shortcut cycles backward through recent tasks", async ({
testPage,
apiClient,
seedData,
}) => {
const first = await apiClient.createTask(seedData.workspaceId, "Reverse Switcher One", {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
});
const second = await apiClient.createTask(seedData.workspaceId, "Reverse Switcher Two", {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
});
const third = await apiClient.createTask(seedData.workspaceId, "Reverse Switcher Three", {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
});
// Visit oldest -> newest so the recent list is [third(current), second, first].
const titleByTask = new Map([
[first.id, "Reverse Switcher One"],
[second.id, "Reverse Switcher Two"],
[third.id, "Reverse Switcher Three"],
]);
for (const task of [first, second, third]) {
await testPage.goto(`/t/${task.id}`);
await expect(
testPage.getByText(titleByTask.get(task.id)!, { exact: true }).first(),
).toBeVisible({ timeout: 15_000 });
}
const modifier = taskSwitcherModifier();
await testPage.keyboard.down(modifier);
await testPage.keyboard.down("Shift");
await testPage.keyboard.press("Space");
const switcher = testPage.getByTestId("recent-task-switcher");
await expect(switcher).toBeVisible({ timeout: 5_000 });
// Reverse opens on the oldest non-current task (first).
const firstRow = switcher.getByTestId(`recent-task-switcher-item-${first.id}`);
const secondRow = switcher.getByTestId(`recent-task-switcher-item-${second.id}`);
await expect(firstRow).toHaveAttribute("data-selected", "true");
// Another reverse step moves selection backward to the newer "second" task.
await testPage.keyboard.press("Space");
await expect(secondRow).toHaveAttribute("data-selected", "true");
// Releasing Shift alone must not commit — only the hold modifier does.
await testPage.keyboard.up("Shift");
await expect(switcher).toBeVisible();
await expect(secondRow).toHaveAttribute("data-selected", "true");
await testPage.keyboard.up(modifier);
await expect(switcher).not.toBeVisible({ timeout: 5_000 });
await expect(testPage).toHaveURL(new RegExp(`/t/${second.id}`), { timeout: 10_000 });
});
test("escape cancels the held switcher without routing on release", async ({
testPage,
apiClient,
seedData,
}) => {
const first = await apiClient.createTask(seedData.workspaceId, "Recent Switcher Cancel One", {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
});
const second = await apiClient.createTask(seedData.workspaceId, "Recent Switcher Cancel Two", {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
});
await testPage.goto(`/t/${first.id}`);
await expect(testPage.getByText("Recent Switcher Cancel One").first()).toBeVisible({
timeout: 15_000,
});
await testPage.goto(`/t/${second.id}`);
await expect(testPage.getByText("Recent Switcher Cancel Two").first()).toBeVisible({
timeout: 15_000,
});
const modifier = taskSwitcherModifier();
await testPage.keyboard.down(modifier);
await testPage.keyboard.press("Space");
const switcher = testPage.getByTestId("recent-task-switcher");
await expect(switcher).toBeVisible({ timeout: 5_000 });
await testPage.keyboard.press("Escape");
await expect(switcher).not.toBeVisible({ timeout: 5_000 });
await testPage.keyboard.up(modifier);
await expect(testPage).toHaveURL(new RegExp(`/t/${second.id}`));
});
});