feat(web): add cmd+shift+g shortcut to open the task's github pr (#1757)
* feat(web): add cmd+shift+g shortcut to open the task's github pr Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: address PR review feedback on focus contract and null task guard Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Kandev Agent <agent@kandev.dev> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -13,6 +13,7 @@ import { EnsureSessionErrorBanner } from "@/components/task/ensure-session-error
|
||||
import type { Layout } from "react-resizable-panels";
|
||||
import { TaskArchivedProvider } from "./task-archived-context";
|
||||
import { SessionCommands } from "@/components/session-commands";
|
||||
import { TaskPRShortcut } from "@/components/task/task-pr-shortcut";
|
||||
import { VcsDialogsProvider } from "@/components/vcs/vcs-dialogs";
|
||||
import {
|
||||
buildDebugEntries,
|
||||
@@ -269,6 +270,7 @@ export function TaskPageInner({
|
||||
hasWorktree={Boolean(merged.worktreeBranch)}
|
||||
isPassthrough={sessionPanel.isSessionPassthrough}
|
||||
/>
|
||||
<TaskPRShortcut taskId={taskProps.taskId} />
|
||||
{debugEntries && <DebugOverlay title="Task Debug" entries={debugEntries} />}
|
||||
{!isMobile && <TaskTopBar {...topBarProps} />}
|
||||
{ensureSession.status === "error" && (
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { resolveTaskPROpenAction } from "./task-pr-open";
|
||||
import type { TaskPR } from "@/lib/types/github";
|
||||
|
||||
function makePR(overrides: Partial<TaskPR>): TaskPR {
|
||||
return {
|
||||
id: "pr-1",
|
||||
task_id: "task-1",
|
||||
owner: "kdlbs",
|
||||
repo: "kandev",
|
||||
pr_number: 1,
|
||||
pr_url: "https://github.com/kdlbs/kandev/pull/1",
|
||||
pr_title: "Test PR",
|
||||
head_branch: "feature/x",
|
||||
base_branch: "main",
|
||||
author_login: "jcfs",
|
||||
state: "open",
|
||||
review_state: "",
|
||||
checks_state: "",
|
||||
mergeable_state: "",
|
||||
review_count: 0,
|
||||
pending_review_count: 0,
|
||||
comment_count: 0,
|
||||
unresolved_review_threads: 0,
|
||||
checks_total: 0,
|
||||
checks_passing: 0,
|
||||
additions: 0,
|
||||
deletions: 0,
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
merged_at: null,
|
||||
closed_at: null,
|
||||
last_synced_at: null,
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("resolveTaskPROpenAction", () => {
|
||||
it("returns none when the task has no linked PRs", () => {
|
||||
expect(resolveTaskPROpenAction([])).toEqual({ kind: "none" });
|
||||
});
|
||||
|
||||
it("opens directly when exactly one PR is linked", () => {
|
||||
const pr = makePR({ id: "only" });
|
||||
expect(resolveTaskPROpenAction([pr])).toEqual({ kind: "open", pr });
|
||||
});
|
||||
|
||||
it("asks for a pick when several PRs are linked", () => {
|
||||
const prs = [makePR({ id: "a" }), makePR({ id: "b", pr_number: 2 })];
|
||||
expect(resolveTaskPROpenAction(prs)).toEqual({ kind: "pick", prs });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { TaskPR } from "@/lib/types/github";
|
||||
|
||||
export type TaskPROpenAction =
|
||||
| { kind: "none" }
|
||||
| { kind: "open"; pr: TaskPR }
|
||||
| { kind: "pick"; prs: TaskPR[] };
|
||||
|
||||
/**
|
||||
* Decide what the "open task PR" shortcut should do: nothing when the task has
|
||||
* no linked PRs, open directly when there is exactly one, or show the picker
|
||||
* dialog when there are several.
|
||||
*/
|
||||
export function resolveTaskPROpenAction(prs: TaskPR[]): TaskPROpenAction {
|
||||
if (prs.length === 0) return { kind: "none" };
|
||||
if (prs.length === 1) return { kind: "open", pr: prs[0] };
|
||||
return { kind: "pick", prs };
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, type KeyboardEvent } from "react";
|
||||
import { IconGitPullRequest } from "@tabler/icons-react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@kandev/ui/dialog";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getPRStatusColor } from "@/components/github/pr-task-icon";
|
||||
import { openExternalLink } from "@/lib/desktop/external-links";
|
||||
import type { TaskPR } from "@/lib/types/github";
|
||||
|
||||
type TaskPRPickerDialogProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
prs: TaskPR[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Picker shown by the open-task-PR shortcut when a task has several linked
|
||||
* PRs. A scrollable list of rows; ArrowUp/ArrowDown move focus (wrapping),
|
||||
* Enter or click opens the focused PR on GitHub and closes the dialog.
|
||||
*/
|
||||
export function TaskPRPickerDialog({ open, onOpenChange, prs }: TaskPRPickerDialogProps) {
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const openPR = (pr: TaskPR) => {
|
||||
void openExternalLink(pr.pr_url).catch(() => undefined);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const onListKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return;
|
||||
e.preventDefault();
|
||||
const rows = Array.from(
|
||||
listRef.current?.querySelectorAll<HTMLButtonElement>("button[data-pr-row]") ?? [],
|
||||
);
|
||||
if (rows.length === 0) return;
|
||||
const idx = rows.findIndex((row) => row === document.activeElement);
|
||||
const delta = e.key === "ArrowDown" ? 1 : -1;
|
||||
if (idx === -1) {
|
||||
rows[delta === 1 ? 0 : rows.length - 1].focus();
|
||||
return;
|
||||
}
|
||||
rows[(idx + delta + rows.length) % rows.length].focus();
|
||||
};
|
||||
|
||||
// Focus the first PR row explicitly on open instead of relying on Radix's
|
||||
// implicit first-focusable ordering, so keyboard flow (ArrowDown/Enter)
|
||||
// stays stable if the dialog template's DOM order changes.
|
||||
const focusFirstRow = (e: Event) => {
|
||||
e.preventDefault();
|
||||
listRef.current?.querySelector<HTMLButtonElement>("button[data-pr-row]")?.focus();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="w-[calc(100vw-2rem)] sm:max-w-lg" onOpenAutoFocus={focusFirstRow}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Open pull request</DialogTitle>
|
||||
<DialogDescription>
|
||||
This task has {prs.length} linked pull requests. Choose one to open on GitHub.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div
|
||||
ref={listRef}
|
||||
data-testid="task-pr-picker-list"
|
||||
className="-mx-2 flex max-h-[50vh] flex-col gap-1 overflow-y-auto px-2"
|
||||
onKeyDown={onListKeyDown}
|
||||
>
|
||||
{prs.map((pr) => (
|
||||
<button
|
||||
key={pr.id}
|
||||
type="button"
|
||||
data-pr-row
|
||||
data-testid={`task-pr-picker-row-${pr.id}`}
|
||||
onClick={() => openPR(pr)}
|
||||
className="flex cursor-pointer items-center gap-2 rounded-md px-2 py-2 text-left text-sm transition-colors hover:bg-accent focus:bg-accent focus:outline-none"
|
||||
>
|
||||
<IconGitPullRequest className={cn("h-4 w-4 shrink-0", getPRStatusColor(pr))} />
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
<span className="font-medium">
|
||||
{pr.repo} #{pr.pr_number}
|
||||
</span>{" "}
|
||||
<span className="text-muted-foreground">{pr.pr_title}</span>
|
||||
</span>
|
||||
<span className="shrink-0 text-xs capitalize text-muted-foreground">{pr.state}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useToast } from "@/components/toast-provider";
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
import { useTaskPR } from "@/hooks/domains/github/use-task-pr";
|
||||
import { useKeyboardShortcut } from "@/hooks/use-keyboard-shortcut";
|
||||
import { getShortcut } from "@/lib/keyboard/shortcut-overrides";
|
||||
import { openExternalLink } from "@/lib/desktop/external-links";
|
||||
import { resolveTaskPROpenAction } from "./task-pr-open";
|
||||
import { TaskPRPickerDialog } from "./task-pr-picker-dialog";
|
||||
|
||||
/**
|
||||
* Task-screen keybinding (default Cmd/Ctrl+Shift+G) that jumps straight to the
|
||||
* task's GitHub pull request. One linked PR opens directly; several open a
|
||||
* picker dialog; none shows a toast.
|
||||
*/
|
||||
export function TaskPRShortcut({ taskId }: { taskId: string | null }) {
|
||||
const { toast } = useToast();
|
||||
const { prs } = useTaskPR(taskId);
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const overrides = useAppStore((s) => s.userSettings.keyboardShortcuts);
|
||||
|
||||
useKeyboardShortcut(
|
||||
getShortcut("OPEN_TASK_PR", overrides),
|
||||
() => {
|
||||
const action = resolveTaskPROpenAction(prs);
|
||||
if (action.kind === "none") {
|
||||
toast({ description: "No pull request linked to this task" });
|
||||
return;
|
||||
}
|
||||
if (action.kind === "open") {
|
||||
void openExternalLink(action.pr.pr_url).catch(() => undefined);
|
||||
return;
|
||||
}
|
||||
setPickerOpen(true);
|
||||
},
|
||||
// Capture + stopPropagation so the binding wins over focus-trapped
|
||||
// surfaces (xterm.js, editors) — mirrors useEditorKeybinds. Disabled
|
||||
// until the task id resolves so a transient null doesn't toast.
|
||||
{ capture: true, stopPropagation: true, enabled: !!taskId },
|
||||
);
|
||||
|
||||
return <TaskPRPickerDialog open={pickerOpen} onOpenChange={setPickerOpen} prs={prs} />;
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
import { KanbanPage } from "../../pages/kanban-page";
|
||||
import { SessionPage } from "../../pages/session-page";
|
||||
import type { ApiClient } from "../../helpers/api-client";
|
||||
|
||||
const OWNER = "acme";
|
||||
const SHORTCUT = "ControlOrMeta+Shift+G";
|
||||
|
||||
type SeedResult = {
|
||||
workflowId: string;
|
||||
workingStepId: string;
|
||||
doneStepId: string;
|
||||
taskId: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Stand up a workspace + workflow + task that reaches the Done column
|
||||
* immediately (auto-start + on_turn_complete moves it), mirroring
|
||||
* pr-multi-popover.spec.ts.
|
||||
*/
|
||||
async function seedTask(
|
||||
apiClient: ApiClient,
|
||||
workspaceId: string,
|
||||
agentProfileId: string,
|
||||
repositoryId: string,
|
||||
title: string,
|
||||
): Promise<SeedResult> {
|
||||
const workflow = await apiClient.createWorkflow(workspaceId, `${title} Workflow`);
|
||||
const inbox = await apiClient.createWorkflowStep(workflow.id, "Inbox", 0);
|
||||
const working = await apiClient.createWorkflowStep(workflow.id, "Working", 1);
|
||||
const done = await apiClient.createWorkflowStep(workflow.id, "Done", 2);
|
||||
|
||||
await apiClient.updateWorkflowStep(working.id, {
|
||||
prompt: 'e2e:message("done")\n{{task_prompt}}',
|
||||
events: {
|
||||
on_enter: [{ type: "auto_start_agent" }],
|
||||
on_turn_complete: [{ type: "move_to_step", config: { step_id: done.id } }],
|
||||
},
|
||||
});
|
||||
|
||||
await apiClient.saveUserSettings({
|
||||
workspace_id: workspaceId,
|
||||
workflow_filter_id: workflow.id,
|
||||
enable_preview_on_click: false,
|
||||
});
|
||||
|
||||
await apiClient.mockGitHubReset();
|
||||
await apiClient.mockGitHubSetUser("test-user");
|
||||
|
||||
const task = await apiClient.createTask(workspaceId, title, {
|
||||
workflow_id: workflow.id,
|
||||
workflow_step_id: inbox.id,
|
||||
agent_profile_id: agentProfileId,
|
||||
repository_ids: [repositoryId],
|
||||
});
|
||||
|
||||
return {
|
||||
workflowId: workflow.id,
|
||||
workingStepId: working.id,
|
||||
doneStepId: done.id,
|
||||
taskId: task.id,
|
||||
};
|
||||
}
|
||||
|
||||
async function associatePR(
|
||||
apiClient: ApiClient,
|
||||
taskId: string,
|
||||
repo: string,
|
||||
prNumber: number,
|
||||
title: string,
|
||||
) {
|
||||
await apiClient.mockGitHubAssociateTaskPR({
|
||||
task_id: taskId,
|
||||
owner: OWNER,
|
||||
repo,
|
||||
pr_number: prNumber,
|
||||
pr_url: `https://github.com/${OWNER}/${repo}/pull/${prNumber}`,
|
||||
pr_title: title,
|
||||
head_branch: `feat/${repo}`,
|
||||
base_branch: "main",
|
||||
author_login: "test-user",
|
||||
state: "open",
|
||||
checks_state: "success",
|
||||
checks_total: 4,
|
||||
checks_passing: 4,
|
||||
review_state: "approved",
|
||||
review_count: 1,
|
||||
});
|
||||
}
|
||||
|
||||
async function openTaskAndWait(
|
||||
testPage: import("@playwright/test").Page,
|
||||
apiClient: ApiClient,
|
||||
seed: SeedResult,
|
||||
title: string,
|
||||
): Promise<SessionPage> {
|
||||
const kanban = new KanbanPage(testPage);
|
||||
await kanban.goto();
|
||||
await apiClient.moveTask(seed.taskId, seed.workflowId, seed.workingStepId);
|
||||
await expect(kanban.taskCardInColumn(title, seed.doneStepId)).toBeVisible({ timeout: 45_000 });
|
||||
await kanban.taskCardInColumn(title, seed.doneStepId).click();
|
||||
await expect(testPage).toHaveURL(/\/[st]\//, { timeout: 15_000 });
|
||||
const session = new SessionPage(testPage);
|
||||
await session.waitForLoad();
|
||||
await expect(session.prTopbarButton()).toBeVisible({ timeout: 15_000 });
|
||||
return session;
|
||||
}
|
||||
|
||||
test.describe("Open-task-PR keyboard shortcut", () => {
|
||||
test("Cmd+Shift+G with several linked PRs opens the picker; Enter opens the focused PR", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
prCapture,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const title = "PR Shortcut Picker";
|
||||
const seed = await seedTask(
|
||||
apiClient,
|
||||
seedData.workspaceId,
|
||||
seedData.agentProfileId,
|
||||
seedData.repositoryId,
|
||||
title,
|
||||
);
|
||||
await associatePR(apiClient, seed.taskId, "web", 42, "Web feature PR");
|
||||
await associatePR(apiClient, seed.taskId, "api", 77, "API feature PR");
|
||||
const session = await openTaskAndWait(testPage, apiClient, seed, title);
|
||||
await expect(session.prTopbarButton()).toHaveAttribute("data-pr-count", "2");
|
||||
|
||||
// Keep the picked PR's navigation offline-deterministic.
|
||||
await testPage
|
||||
.context()
|
||||
.route("https://github.com/**", (route) =>
|
||||
route.fulfill({ contentType: "text/html", body: "<html>github stub</html>" }),
|
||||
);
|
||||
|
||||
await testPage.keyboard.press(SHORTCUT);
|
||||
const list = testPage.getByTestId("task-pr-picker-list");
|
||||
await expect(list).toBeVisible();
|
||||
await expect(list.locator("button[data-pr-row]")).toHaveCount(2);
|
||||
await prCapture.screenshot("pr-picker-modal", {
|
||||
caption: "Cmd+Shift+G on a task with two linked PRs opens the picker modal",
|
||||
});
|
||||
|
||||
// First row (web#42) is auto-focused; ArrowDown moves to api#77, Enter opens it.
|
||||
await testPage.keyboard.press("ArrowDown");
|
||||
const popupPromise = testPage.context().waitForEvent("page");
|
||||
await testPage.keyboard.press("Enter");
|
||||
const popup = await popupPromise;
|
||||
await popup.waitForURL(`https://github.com/${OWNER}/api/pull/77`, { timeout: 10_000 });
|
||||
await expect(list).not.toBeVisible();
|
||||
await popup.close();
|
||||
});
|
||||
|
||||
test("Cmd+Shift+G with one linked PR opens it directly without a modal", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const title = "PR Shortcut Direct";
|
||||
const seed = await seedTask(
|
||||
apiClient,
|
||||
seedData.workspaceId,
|
||||
seedData.agentProfileId,
|
||||
seedData.repositoryId,
|
||||
title,
|
||||
);
|
||||
await associatePR(apiClient, seed.taskId, "web", 42, "Web feature PR");
|
||||
await openTaskAndWait(testPage, apiClient, seed, title);
|
||||
|
||||
await testPage
|
||||
.context()
|
||||
.route("https://github.com/**", (route) =>
|
||||
route.fulfill({ contentType: "text/html", body: "<html>github stub</html>" }),
|
||||
);
|
||||
|
||||
const popupPromise = testPage.context().waitForEvent("page");
|
||||
await testPage.keyboard.press(SHORTCUT);
|
||||
const popup = await popupPromise;
|
||||
await popup.waitForURL(`https://github.com/${OWNER}/web/pull/42`, { timeout: 10_000 });
|
||||
await expect(testPage.getByTestId("task-pr-picker-list")).not.toBeVisible();
|
||||
await popup.close();
|
||||
});
|
||||
|
||||
test("shortcut is rebindable from the general settings page", async ({ testPage, prCapture }) => {
|
||||
await testPage.goto("/settings/general/keyboard-shortcuts");
|
||||
const recorder = testPage.getByTestId("shortcut-recorder-OPEN_TASK_PR");
|
||||
await recorder.scrollIntoViewIfNeeded();
|
||||
await expect(recorder).toBeVisible();
|
||||
await prCapture.screenshot("settings-shortcut-row", {
|
||||
caption: "The shortcut is rebindable in Settings — Keyboard Shortcuts",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -176,4 +176,10 @@ export const SHORTCUTS = {
|
||||
key: KEYS.R,
|
||||
modifiers: { ctrl: true },
|
||||
},
|
||||
// Cmd+Shift+G on a task screen jumps straight to the task's GitHub PR
|
||||
// (or opens a picker dialog when the task has several linked PRs).
|
||||
OPEN_TASK_PR: {
|
||||
key: KEYS.G,
|
||||
modifiers: { ctrlOrCmd: true, shift: true },
|
||||
},
|
||||
} as const;
|
||||
|
||||
@@ -26,7 +26,8 @@ describe("CONFIGURABLE_SHORTCUTS", () => {
|
||||
expect(ids).toContain("TASK_SWITCHER_REVERSE");
|
||||
expect(ids).toContain("VOICE_INPUT_TOGGLE");
|
||||
expect(ids).toContain("REVERSE_SEARCH");
|
||||
expect(ids).toHaveLength(14);
|
||||
expect(ids).toContain("OPEN_TASK_PR");
|
||||
expect(ids).toHaveLength(15);
|
||||
});
|
||||
|
||||
it("each entry has a label and default matching SHORTCUTS", () => {
|
||||
@@ -65,6 +66,9 @@ describe("CONFIGURABLE_SHORTCUTS", () => {
|
||||
|
||||
expect(CONFIGURABLE_SHORTCUTS.REVERSE_SEARCH.label).toBe("Reverse Chat Search");
|
||||
expect(CONFIGURABLE_SHORTCUTS.REVERSE_SEARCH.default).toBe(SHORTCUTS.REVERSE_SEARCH);
|
||||
|
||||
expect(CONFIGURABLE_SHORTCUTS.OPEN_TASK_PR.label).toBe("Open Task Pull Request");
|
||||
expect(CONFIGURABLE_SHORTCUTS.OPEN_TASK_PR.default).toBe(SHORTCUTS.OPEN_TASK_PR);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -14,7 +14,8 @@ export type ConfigurableShortcutId =
|
||||
| "TASK_SWITCHER"
|
||||
| "TASK_SWITCHER_REVERSE"
|
||||
| "VOICE_INPUT_TOGGLE"
|
||||
| "REVERSE_SEARCH";
|
||||
| "REVERSE_SEARCH"
|
||||
| "OPEN_TASK_PR";
|
||||
|
||||
export type StoredShortcutOverrides = Record<
|
||||
string,
|
||||
@@ -56,6 +57,7 @@ export const CONFIGURABLE_SHORTCUTS: Record<
|
||||
},
|
||||
VOICE_INPUT_TOGGLE: { label: "Voice Input", default: SHORTCUTS.VOICE_INPUT_TOGGLE },
|
||||
REVERSE_SEARCH: { label: "Reverse Chat Search", default: SHORTCUTS.REVERSE_SEARCH },
|
||||
OPEN_TASK_PR: { label: "Open Task Pull Request", default: SHORTCUTS.OPEN_TASK_PR },
|
||||
};
|
||||
|
||||
export function getShortcut(
|
||||
|
||||
Reference in New Issue
Block a user