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.
This commit is contained in:
@@ -27,6 +27,7 @@ import {
|
||||
import {
|
||||
buildRecentTaskDisplayItems,
|
||||
buildRecentTaskEntry,
|
||||
getInitialReverseSelectionIndex,
|
||||
getInitialSelectionIndex,
|
||||
getNextSelectionIndex,
|
||||
getPreviousSelectionIndex,
|
||||
@@ -39,6 +40,9 @@ import {
|
||||
isCycleShortcutEvent,
|
||||
} from "./recent-task-switcher-keys";
|
||||
|
||||
/** Direction the switcher cycles through recent tasks. */
|
||||
type CycleDirection = "forward" | "backward";
|
||||
|
||||
export type RecentTaskSwitcherController = {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
@@ -46,6 +50,7 @@ export type RecentTaskSwitcherController = {
|
||||
selectedIndex: number;
|
||||
setSelectedIndex: (index: number) => void;
|
||||
shortcutLabel: string;
|
||||
reverseShortcutLabel: string;
|
||||
selectItem: (item: RecentTaskDisplayItem | undefined) => void;
|
||||
handleKeyDown: (event: ReactKeyboardEvent) => void;
|
||||
};
|
||||
@@ -58,14 +63,19 @@ type SwitcherRefs = {
|
||||
itemsRef: MutableRefObject<RecentTaskDisplayItem[]>;
|
||||
activeTaskIdRef: MutableRefObject<string | null>;
|
||||
shortcutRef: MutableRefObject<KeyboardShortcut>;
|
||||
reverseShortcutRef: MutableRefObject<KeyboardShortcut>;
|
||||
// The binding whose hold-modifier release should commit — set to the shortcut
|
||||
// of the direction that opened/last drove the switcher, so a divergent custom
|
||||
// binding for the other direction can't commit early.
|
||||
activeCommitShortcutRef: MutableRefObject<KeyboardShortcut | null>;
|
||||
};
|
||||
|
||||
type SwitcherActions = {
|
||||
setOpen: (open: boolean) => void;
|
||||
setSelectedIndex: (index: number) => void;
|
||||
selectItem: (item: RecentTaskDisplayItem | undefined) => void;
|
||||
openSwitcher: (commitOnRelease: boolean) => void;
|
||||
cycleSwitcher: (commitOnRelease: boolean) => void;
|
||||
openSwitcher: (commitOnRelease: boolean, direction?: CycleDirection) => void;
|
||||
cycleSwitcher: (commitOnRelease: boolean, direction?: CycleDirection) => void;
|
||||
cancelSwitcher: () => void;
|
||||
selectCurrentItem: () => void;
|
||||
};
|
||||
@@ -182,13 +192,21 @@ function useLatestRef<T>(value: T) {
|
||||
return ref;
|
||||
}
|
||||
|
||||
function useSwitcherRefs(
|
||||
open: boolean,
|
||||
selectedIndex: number,
|
||||
items: RecentTaskDisplayItem[],
|
||||
activeTaskId: string | null,
|
||||
shortcut: KeyboardShortcut,
|
||||
): SwitcherRefs {
|
||||
function useSwitcherRefs({
|
||||
open,
|
||||
selectedIndex,
|
||||
items,
|
||||
activeTaskId,
|
||||
shortcut,
|
||||
reverseShortcut,
|
||||
}: {
|
||||
open: boolean;
|
||||
selectedIndex: number;
|
||||
items: RecentTaskDisplayItem[];
|
||||
activeTaskId: string | null;
|
||||
shortcut: KeyboardShortcut;
|
||||
reverseShortcut: KeyboardShortcut;
|
||||
}): SwitcherRefs {
|
||||
const openRef = useRef(open);
|
||||
const selectedIndexRef = useRef(selectedIndex);
|
||||
const commitOnReleaseRef = useRef(false);
|
||||
@@ -196,6 +214,8 @@ function useSwitcherRefs(
|
||||
const itemsRef = useLatestRef(items);
|
||||
const activeTaskIdRef = useLatestRef(activeTaskId);
|
||||
const shortcutRef = useLatestRef(shortcut);
|
||||
const reverseShortcutRef = useLatestRef(reverseShortcut);
|
||||
const activeCommitShortcutRef = useRef<KeyboardShortcut | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
openRef.current = open;
|
||||
@@ -210,6 +230,8 @@ function useSwitcherRefs(
|
||||
itemsRef,
|
||||
activeTaskIdRef,
|
||||
shortcutRef,
|
||||
reverseShortcutRef,
|
||||
activeCommitShortcutRef,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -228,6 +250,95 @@ function useSelectedIndexSetter(refs: SwitcherRefs, setRawSelectedIndex: (index:
|
||||
);
|
||||
}
|
||||
|
||||
type SwitcherCycleActions = Pick<SwitcherActions, "openSwitcher" | "cycleSwitcher">;
|
||||
|
||||
function useSwitcherCycleActions({
|
||||
refs,
|
||||
setCommandPanelOpen,
|
||||
setOpenState,
|
||||
setSelectedIndex,
|
||||
}: {
|
||||
refs: SwitcherRefs;
|
||||
setCommandPanelOpen: (open: boolean) => void;
|
||||
setOpenState: (open: boolean) => void;
|
||||
setSelectedIndex: (index: number) => void;
|
||||
}): SwitcherCycleActions {
|
||||
const {
|
||||
activeCommitShortcutRef,
|
||||
activeTaskIdRef,
|
||||
cancelledRef,
|
||||
commitOnReleaseRef,
|
||||
itemsRef,
|
||||
openRef,
|
||||
reverseShortcutRef,
|
||||
selectedIndexRef,
|
||||
shortcutRef,
|
||||
} = refs;
|
||||
|
||||
const commitShortcutForDirection = useCallback(
|
||||
(direction: CycleDirection) =>
|
||||
direction === "backward" ? reverseShortcutRef.current : shortcutRef.current,
|
||||
[reverseShortcutRef, shortcutRef],
|
||||
);
|
||||
|
||||
const openSwitcher = useCallback(
|
||||
(commitOnRelease: boolean, direction: CycleDirection = "forward") => {
|
||||
setCommandPanelOpen(false);
|
||||
cancelledRef.current = false;
|
||||
commitOnReleaseRef.current = commitOnRelease;
|
||||
activeCommitShortcutRef.current = commitShortcutForDirection(direction);
|
||||
openRef.current = true;
|
||||
const initialIndex =
|
||||
direction === "backward"
|
||||
? getInitialReverseSelectionIndex(itemsRef.current, activeTaskIdRef.current)
|
||||
: getInitialSelectionIndex(itemsRef.current, activeTaskIdRef.current);
|
||||
setSelectedIndex(initialIndex);
|
||||
setOpenState(true);
|
||||
},
|
||||
[
|
||||
activeCommitShortcutRef,
|
||||
activeTaskIdRef,
|
||||
cancelledRef,
|
||||
commitOnReleaseRef,
|
||||
commitShortcutForDirection,
|
||||
itemsRef,
|
||||
openRef,
|
||||
setCommandPanelOpen,
|
||||
setOpenState,
|
||||
setSelectedIndex,
|
||||
],
|
||||
);
|
||||
|
||||
const cycleSwitcher = useCallback(
|
||||
(commitOnRelease: boolean, direction: CycleDirection = "forward") => {
|
||||
if (!openRef.current) {
|
||||
openSwitcher(commitOnRelease, direction);
|
||||
return;
|
||||
}
|
||||
if (commitOnRelease) commitOnReleaseRef.current = true;
|
||||
activeCommitShortcutRef.current = commitShortcutForDirection(direction);
|
||||
const count = itemsRef.current.length;
|
||||
const nextIndex =
|
||||
direction === "backward"
|
||||
? getPreviousSelectionIndex(selectedIndexRef.current, count)
|
||||
: getNextSelectionIndex(selectedIndexRef.current, count);
|
||||
setSelectedIndex(nextIndex);
|
||||
},
|
||||
[
|
||||
activeCommitShortcutRef,
|
||||
commitOnReleaseRef,
|
||||
commitShortcutForDirection,
|
||||
itemsRef,
|
||||
openRef,
|
||||
openSwitcher,
|
||||
selectedIndexRef,
|
||||
setSelectedIndex,
|
||||
],
|
||||
);
|
||||
|
||||
return { openSwitcher, cycleSwitcher };
|
||||
}
|
||||
|
||||
function useSwitcherActions({
|
||||
refs,
|
||||
routeToTask,
|
||||
@@ -241,14 +352,27 @@ function useSwitcherActions({
|
||||
setOpenState: (open: boolean) => void;
|
||||
setSelectedIndex: (index: number) => void;
|
||||
}): SwitcherActions {
|
||||
const { activeTaskIdRef, cancelledRef, commitOnReleaseRef, itemsRef, openRef, selectedIndexRef } =
|
||||
refs;
|
||||
const {
|
||||
activeCommitShortcutRef,
|
||||
cancelledRef,
|
||||
commitOnReleaseRef,
|
||||
itemsRef,
|
||||
openRef,
|
||||
selectedIndexRef,
|
||||
} = refs;
|
||||
const { openSwitcher, cycleSwitcher } = useSwitcherCycleActions({
|
||||
refs,
|
||||
setCommandPanelOpen,
|
||||
setOpenState,
|
||||
setSelectedIndex,
|
||||
});
|
||||
|
||||
const closeSwitcher = useCallback(() => {
|
||||
openRef.current = false;
|
||||
commitOnReleaseRef.current = false;
|
||||
activeCommitShortcutRef.current = null;
|
||||
setOpenState(false);
|
||||
}, [commitOnReleaseRef, openRef, setOpenState]);
|
||||
}, [activeCommitShortcutRef, commitOnReleaseRef, openRef, setOpenState]);
|
||||
|
||||
const cancelSwitcher = useCallback(() => {
|
||||
cancelledRef.current = true;
|
||||
@@ -276,39 +400,6 @@ function useSwitcherActions({
|
||||
[closeSwitcher, routeToTask],
|
||||
);
|
||||
|
||||
const openSwitcher = useCallback(
|
||||
(commitOnRelease: boolean) => {
|
||||
setCommandPanelOpen(false);
|
||||
cancelledRef.current = false;
|
||||
commitOnReleaseRef.current = commitOnRelease;
|
||||
openRef.current = true;
|
||||
setSelectedIndex(getInitialSelectionIndex(itemsRef.current, activeTaskIdRef.current));
|
||||
setOpenState(true);
|
||||
},
|
||||
[
|
||||
activeTaskIdRef,
|
||||
cancelledRef,
|
||||
commitOnReleaseRef,
|
||||
itemsRef,
|
||||
openRef,
|
||||
setCommandPanelOpen,
|
||||
setOpenState,
|
||||
setSelectedIndex,
|
||||
],
|
||||
);
|
||||
|
||||
const cycleSwitcher = useCallback(
|
||||
(commitOnRelease: boolean) => {
|
||||
if (!openRef.current) {
|
||||
openSwitcher(commitOnRelease);
|
||||
return;
|
||||
}
|
||||
if (commitOnRelease) commitOnReleaseRef.current = true;
|
||||
setSelectedIndex(getNextSelectionIndex(selectedIndexRef.current, itemsRef.current.length));
|
||||
},
|
||||
[commitOnReleaseRef, itemsRef, openRef, openSwitcher, selectedIndexRef, setSelectedIndex],
|
||||
);
|
||||
|
||||
const selectCurrentItem = useCallback(() => {
|
||||
selectItem(itemsRef.current[selectedIndexRef.current]);
|
||||
}, [itemsRef, selectItem, selectedIndexRef]);
|
||||
@@ -342,7 +433,14 @@ function useRecentTaskSwitcherCommand(shortcut: KeyboardShortcut, openSwitcher:
|
||||
}
|
||||
|
||||
function useSwitcherGlobalKeyboard(refs: SwitcherRefs, actions: SwitcherActions) {
|
||||
const { cancelledRef, commitOnReleaseRef, openRef, shortcutRef } = refs;
|
||||
const {
|
||||
activeCommitShortcutRef,
|
||||
cancelledRef,
|
||||
commitOnReleaseRef,
|
||||
openRef,
|
||||
reverseShortcutRef,
|
||||
shortcutRef,
|
||||
} = refs;
|
||||
const { cancelSwitcher, cycleSwitcher, selectCurrentItem } = actions;
|
||||
|
||||
useEffect(() => {
|
||||
@@ -354,18 +452,33 @@ function useSwitcherGlobalKeyboard(refs: SwitcherRefs, actions: SwitcherActions)
|
||||
return;
|
||||
}
|
||||
|
||||
// Check the reverse shortcut first: it carries an extra modifier (Shift),
|
||||
// so it is the more specific match and the forward shortcut never matches
|
||||
// a Shift-held event anyway.
|
||||
const reverseShortcut = reverseShortcutRef.current;
|
||||
if (isCycleShortcutEvent(event, reverseShortcut)) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
cycleSwitcher(hasHoldModifier(reverseShortcut), "backward");
|
||||
return;
|
||||
}
|
||||
|
||||
const currentShortcut = shortcutRef.current;
|
||||
if (!isCycleShortcutEvent(event, currentShortcut)) return;
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
cycleSwitcher(hasHoldModifier(currentShortcut));
|
||||
cycleSwitcher(hasHoldModifier(currentShortcut), "forward");
|
||||
};
|
||||
|
||||
const handleKeyUp = (event: KeyboardEvent) => {
|
||||
const currentShortcut = shortcutRef.current;
|
||||
if (!openRef.current || !commitOnReleaseRef.current) return;
|
||||
if (!isCommitReleaseEvent(event, currentShortcut)) return;
|
||||
// Commit only when the hold modifier of the binding that drove the
|
||||
// switcher is released. Both directions default to Ctrl/Cmd, but custom
|
||||
// bindings may differ — releasing the other direction's modifier must not
|
||||
// commit early.
|
||||
const activeShortcut = activeCommitShortcutRef.current;
|
||||
if (!activeShortcut || !isCommitReleaseEvent(event, activeShortcut)) return;
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
@@ -396,11 +509,13 @@ function useSwitcherGlobalKeyboard(refs: SwitcherRefs, actions: SwitcherActions)
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
};
|
||||
}, [
|
||||
activeCommitShortcutRef,
|
||||
cancelSwitcher,
|
||||
cancelledRef,
|
||||
commitOnReleaseRef,
|
||||
cycleSwitcher,
|
||||
openRef,
|
||||
reverseShortcutRef,
|
||||
selectCurrentItem,
|
||||
shortcutRef,
|
||||
]);
|
||||
@@ -451,12 +566,20 @@ export function useRecentTaskSwitcherController(): RecentTaskSwitcherController
|
||||
const { setOpen: setCommandPanelOpen } = useCommandPanelOpen();
|
||||
const keyboardShortcuts = useAppStore((state) => state.userSettings.keyboardShortcuts);
|
||||
const shortcut = getShortcut("TASK_SWITCHER", keyboardShortcuts);
|
||||
const reverseShortcut = getShortcut("TASK_SWITCHER_REVERSE", keyboardShortcuts);
|
||||
const context = useRecentTaskBuildContext();
|
||||
useRecordActiveTask(context);
|
||||
|
||||
const items = useMemo(() => buildRecentTaskDisplayItems(entries, context), [entries, context]);
|
||||
const selectedIndex = getResolvedSelectionIndex(rawSelectedIndex, items, context.activeTaskId);
|
||||
const refs = useSwitcherRefs(open, selectedIndex, items, context.activeTaskId, shortcut);
|
||||
const refs = useSwitcherRefs({
|
||||
open,
|
||||
selectedIndex,
|
||||
items,
|
||||
activeTaskId: context.activeTaskId,
|
||||
shortcut,
|
||||
reverseShortcut,
|
||||
});
|
||||
const setSelectedIndex = useSelectedIndexSetter(refs, setRawSelectedIndex);
|
||||
const routeToTask = useCallback((taskId: string) => router.push(linkToTask(taskId)), [router]);
|
||||
const actions = useSwitcherActions({
|
||||
@@ -479,6 +602,7 @@ export function useRecentTaskSwitcherController(): RecentTaskSwitcherController
|
||||
selectedIndex,
|
||||
setSelectedIndex,
|
||||
shortcutLabel: formatShortcut(shortcut),
|
||||
reverseShortcutLabel: formatShortcut(reverseShortcut),
|
||||
selectItem: actions.selectItem,
|
||||
handleKeyDown,
|
||||
};
|
||||
|
||||
@@ -24,6 +24,11 @@ const taskSwitcherShortcut: KeyboardShortcut = {
|
||||
modifiers: { ctrlOrCmd: true },
|
||||
};
|
||||
|
||||
const taskSwitcherReverseShortcut: KeyboardShortcut = {
|
||||
key: KEYS.SPACE,
|
||||
modifiers: { ctrlOrCmd: true, shift: true },
|
||||
};
|
||||
|
||||
function platform(platform: Platform): Platform {
|
||||
return platform;
|
||||
}
|
||||
@@ -70,6 +75,30 @@ describe("recent task switcher key helpers", () => {
|
||||
expect(isCycleShortcutEvent(event({ key: "y", ctrlKey: true }), shortcut)).toBe(false);
|
||||
});
|
||||
|
||||
it("separates forward and reverse switcher shortcuts by the Shift modifier", () => {
|
||||
const forwardEvent = event({ key: KEYS.SPACE, ctrlKey: true });
|
||||
const reverseEvent = event({ key: KEYS.SPACE, ctrlKey: true, shiftKey: true });
|
||||
|
||||
// Forward fires only without Shift; reverse fires only with Shift.
|
||||
expect(isCycleShortcutEvent(forwardEvent, taskSwitcherShortcut)).toBe(true);
|
||||
expect(isCycleShortcutEvent(forwardEvent, taskSwitcherReverseShortcut)).toBe(false);
|
||||
expect(isCycleShortcutEvent(reverseEvent, taskSwitcherShortcut)).toBe(false);
|
||||
expect(isCycleShortcutEvent(reverseEvent, taskSwitcherReverseShortcut)).toBe(true);
|
||||
});
|
||||
|
||||
it("commits the reverse shortcut on Ctrl/Cmd release, not Shift release", () => {
|
||||
expect(
|
||||
isCommitReleaseEvent(
|
||||
event({ key: "Control" }),
|
||||
taskSwitcherReverseShortcut,
|
||||
platform("linux"),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isCommitReleaseEvent(event({ key: "Shift" }), taskSwitcherReverseShortcut, platform("linux")),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("matches the hold modifier release, not secondary modifier release", () => {
|
||||
const shortcut: KeyboardShortcut = {
|
||||
key: KEYS.Y,
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import {
|
||||
buildRecentTaskDisplayItems,
|
||||
buildRecentTaskEntry,
|
||||
getInitialReverseSelectionIndex,
|
||||
getInitialSelectionIndex,
|
||||
getNextSelectionIndex,
|
||||
getPreviousSelectionIndex,
|
||||
@@ -134,6 +135,26 @@ describe("recent task switcher model", () => {
|
||||
expect(getInitialSelectionIndex([], CURRENT_TASK_ID)).toBe(-1);
|
||||
});
|
||||
|
||||
it("starts reverse selection on the last non-current task", () => {
|
||||
const items = [
|
||||
{ taskId: CURRENT_TASK_ID },
|
||||
{ taskId: PREVIOUS_TASK_ID },
|
||||
{ taskId: "task-older" },
|
||||
];
|
||||
|
||||
expect(getInitialReverseSelectionIndex(items, CURRENT_TASK_ID)).toBe(2);
|
||||
// When the current task is the last item, skip back to the previous one.
|
||||
expect(
|
||||
getInitialReverseSelectionIndex(
|
||||
[{ taskId: PREVIOUS_TASK_ID }, { taskId: CURRENT_TASK_ID }],
|
||||
CURRENT_TASK_ID,
|
||||
),
|
||||
).toBe(0);
|
||||
// All items are current -> fall back to the last index.
|
||||
expect(getInitialReverseSelectionIndex([{ taskId: CURRENT_TASK_ID }], CURRENT_TASK_ID)).toBe(0);
|
||||
expect(getInitialReverseSelectionIndex([], CURRENT_TASK_ID)).toBe(-1);
|
||||
});
|
||||
|
||||
it("cycles through items and handles empty lists", () => {
|
||||
expect(getNextSelectionIndex(0, 3)).toBe(1);
|
||||
expect(getNextSelectionIndex(2, 3)).toBe(0);
|
||||
|
||||
@@ -362,6 +362,17 @@ export function getInitialSelectionIndex(
|
||||
return firstNonCurrent === -1 ? 0 : firstNonCurrent;
|
||||
}
|
||||
|
||||
export function getInitialReverseSelectionIndex(
|
||||
items: Array<{ taskId: string }>,
|
||||
currentTaskId: string | null,
|
||||
): number {
|
||||
if (items.length === 0) return -1;
|
||||
for (let index = items.length - 1; index >= 0; index--) {
|
||||
if (items[index].taskId !== currentTaskId) return index;
|
||||
}
|
||||
return items.length - 1;
|
||||
}
|
||||
|
||||
export function getNextSelectionIndex(currentIndex: number, itemCount: number): number {
|
||||
if (itemCount === 0) return -1;
|
||||
if (currentIndex < 0) return 0;
|
||||
|
||||
@@ -128,10 +128,21 @@ function RecentTaskList({
|
||||
);
|
||||
}
|
||||
|
||||
function SwitcherFooter({ shortcutLabel }: { shortcutLabel: string }) {
|
||||
function SwitcherFooter({
|
||||
shortcutLabel,
|
||||
reverseShortcutLabel,
|
||||
}: {
|
||||
shortcutLabel: string;
|
||||
reverseShortcutLabel: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="border-t px-4 py-2 text-[11px] text-muted-foreground">
|
||||
<Kbd>{shortcutLabel}</Kbd>
|
||||
<div className="flex items-center gap-3 border-t px-4 py-2 text-[11px] text-muted-foreground">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Kbd>{shortcutLabel}</Kbd> Next
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Kbd>{reverseShortcutLabel}</Kbd> Previous
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -160,7 +171,10 @@ function RecentTaskSwitcherDialog(props: RecentTaskSwitcherController) {
|
||||
selectItem={props.selectItem}
|
||||
/>
|
||||
</div>
|
||||
<SwitcherFooter shortcutLabel={props.shortcutLabel} />
|
||||
<SwitcherFooter
|
||||
shortcutLabel={props.shortcutLabel}
|
||||
reverseShortcutLabel={props.reverseShortcutLabel}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -19,6 +19,7 @@ test.describe("Keyboard Shortcuts Settings", () => {
|
||||
await expect(testPage.getByTestId("shortcut-recorder-FOCUS_INPUT")).toBeVisible();
|
||||
await expect(testPage.getByTestId("shortcut-recorder-TOGGLE_PLAN_MODE")).toBeVisible();
|
||||
await expect(testPage.getByTestId("shortcut-recorder-TASK_SWITCHER")).toBeVisible();
|
||||
await expect(testPage.getByTestId("shortcut-recorder-TASK_SWITCHER_REVERSE")).toBeVisible();
|
||||
});
|
||||
|
||||
test("can record a new shortcut and persist it", async ({ testPage, apiClient, seedData }) => {
|
||||
|
||||
@@ -68,6 +68,67 @@ test.describe("Recent task switcher", () => {
|
||||
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,
|
||||
|
||||
@@ -145,6 +145,12 @@ export const SHORTCUTS = {
|
||||
key: KEYS.SPACE,
|
||||
modifiers: { ctrlOrCmd: true },
|
||||
},
|
||||
// Cycle the recent-task switcher backward (oldest -> most-recent). Shift
|
||||
// distinguishes it from TASK_SWITCHER so both can be held together.
|
||||
TASK_SWITCHER_REVERSE: {
|
||||
key: KEYS.SPACE,
|
||||
modifiers: { ctrlOrCmd: true, shift: true },
|
||||
},
|
||||
BOTTOM_TERMINAL: {
|
||||
key: KEYS.J,
|
||||
modifiers: { ctrlOrCmd: true },
|
||||
|
||||
@@ -20,8 +20,9 @@ describe("CONFIGURABLE_SHORTCUTS", () => {
|
||||
expect(ids).toContain("FOCUS_INPUT");
|
||||
expect(ids).toContain("TOGGLE_PLAN_MODE");
|
||||
expect(ids).toContain("TASK_SWITCHER");
|
||||
expect(ids).toContain("TASK_SWITCHER_REVERSE");
|
||||
expect(ids).toContain("VOICE_INPUT_TOGGLE");
|
||||
expect(ids).toHaveLength(11);
|
||||
expect(ids).toHaveLength(12);
|
||||
});
|
||||
|
||||
it("each entry has a label and default matching SHORTCUTS", () => {
|
||||
@@ -45,6 +46,13 @@ describe("CONFIGURABLE_SHORTCUTS", () => {
|
||||
|
||||
expect(CONFIGURABLE_SHORTCUTS.TASK_SWITCHER.label).toBe("Recent Task Switcher");
|
||||
expect(CONFIGURABLE_SHORTCUTS.TASK_SWITCHER.default).toBe(SHORTCUTS.TASK_SWITCHER);
|
||||
|
||||
expect(CONFIGURABLE_SHORTCUTS.TASK_SWITCHER_REVERSE.label).toBe(
|
||||
"Recent Task Switcher (Backward)",
|
||||
);
|
||||
expect(CONFIGURABLE_SHORTCUTS.TASK_SWITCHER_REVERSE.default).toBe(
|
||||
SHORTCUTS.TASK_SWITCHER_REVERSE,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ export type ConfigurableShortcutId =
|
||||
| "FOCUS_INPUT"
|
||||
| "TOGGLE_PLAN_MODE"
|
||||
| "TASK_SWITCHER"
|
||||
| "TASK_SWITCHER_REVERSE"
|
||||
| "VOICE_INPUT_TOGGLE";
|
||||
|
||||
export type StoredShortcutOverrides = Record<
|
||||
@@ -32,6 +33,10 @@ export const CONFIGURABLE_SHORTCUTS: Record<
|
||||
FOCUS_INPUT: { label: "Focus Chat Input", default: SHORTCUTS.FOCUS_INPUT },
|
||||
TOGGLE_PLAN_MODE: { label: "Toggle Plan Mode", default: SHORTCUTS.TOGGLE_PLAN_MODE },
|
||||
TASK_SWITCHER: { label: "Recent Task Switcher", default: SHORTCUTS.TASK_SWITCHER },
|
||||
TASK_SWITCHER_REVERSE: {
|
||||
label: "Recent Task Switcher (Backward)",
|
||||
default: SHORTCUTS.TASK_SWITCHER_REVERSE,
|
||||
},
|
||||
VOICE_INPUT_TOGGLE: { label: "Voice Input", default: SHORTCUTS.VOICE_INPUT_TOGGLE },
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user