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:
João Salavisa
2026-07-17 14:46:04 +01:00
committed by GitHub
parent 1806b87e7e
commit bb290d4281
9 changed files with 423 additions and 2 deletions
@@ -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 });
});
});
+17
View File
@@ -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",
});
});
});
+6
View File
@@ -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);
});
});
+3 -1
View File
@@ -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(