b0b968cb22
* 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.
171 lines
6.5 KiB
TypeScript
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}`));
|
|
});
|
|
});
|