From bb290d42812d7dc58232be648ffc2ddb57853a80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Salavisa?= Date: Fri, 17 Jul 2026 14:46:04 +0100 Subject: [PATCH] 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 * fix: address PR review feedback on focus contract and null task guard Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Kandev Agent Co-authored-by: Claude Fable 5 --- apps/web/components/task/task-page-inner.tsx | 2 + apps/web/components/task/task-pr-open.test.ts | 52 +++++ apps/web/components/task/task-pr-open.ts | 17 ++ .../components/task/task-pr-picker-dialog.tsx | 98 +++++++++ apps/web/components/task/task-pr-shortcut.tsx | 45 ++++ .../web/e2e/tests/pr/pr-open-shortcut.spec.ts | 195 ++++++++++++++++++ apps/web/lib/keyboard/constants.ts | 6 + .../lib/keyboard/shortcut-overrides.test.ts | 6 +- apps/web/lib/keyboard/shortcut-overrides.ts | 4 +- 9 files changed, 423 insertions(+), 2 deletions(-) create mode 100644 apps/web/components/task/task-pr-open.test.ts create mode 100644 apps/web/components/task/task-pr-open.ts create mode 100644 apps/web/components/task/task-pr-picker-dialog.tsx create mode 100644 apps/web/components/task/task-pr-shortcut.tsx create mode 100644 apps/web/e2e/tests/pr/pr-open-shortcut.spec.ts diff --git a/apps/web/components/task/task-page-inner.tsx b/apps/web/components/task/task-page-inner.tsx index ba1bac8577..a26c774b89 100644 --- a/apps/web/components/task/task-page-inner.tsx +++ b/apps/web/components/task/task-page-inner.tsx @@ -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} /> + {debugEntries && } {!isMobile && } {ensureSession.status === "error" && ( diff --git a/apps/web/components/task/task-pr-open.test.ts b/apps/web/components/task/task-pr-open.test.ts new file mode 100644 index 0000000000..d2695a885a --- /dev/null +++ b/apps/web/components/task/task-pr-open.test.ts @@ -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 { + 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 }); + }); +}); diff --git a/apps/web/components/task/task-pr-open.ts b/apps/web/components/task/task-pr-open.ts new file mode 100644 index 0000000000..5cf56be5ae --- /dev/null +++ b/apps/web/components/task/task-pr-open.ts @@ -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 }; +} diff --git a/apps/web/components/task/task-pr-picker-dialog.tsx b/apps/web/components/task/task-pr-picker-dialog.tsx new file mode 100644 index 0000000000..7a96100f90 --- /dev/null +++ b/apps/web/components/task/task-pr-picker-dialog.tsx @@ -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(null); + + const openPR = (pr: TaskPR) => { + void openExternalLink(pr.pr_url).catch(() => undefined); + onOpenChange(false); + }; + + const onListKeyDown = (e: KeyboardEvent) => { + if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return; + e.preventDefault(); + const rows = Array.from( + listRef.current?.querySelectorAll("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("button[data-pr-row]")?.focus(); + }; + + return ( + + + + Open pull request + + This task has {prs.length} linked pull requests. Choose one to open on GitHub. + + +
+ {prs.map((pr) => ( + + ))} +
+
+
+ ); +} diff --git a/apps/web/components/task/task-pr-shortcut.tsx b/apps/web/components/task/task-pr-shortcut.tsx new file mode 100644 index 0000000000..be398100c2 --- /dev/null +++ b/apps/web/components/task/task-pr-shortcut.tsx @@ -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 ; +} diff --git a/apps/web/e2e/tests/pr/pr-open-shortcut.spec.ts b/apps/web/e2e/tests/pr/pr-open-shortcut.spec.ts new file mode 100644 index 0000000000..efe9597aee --- /dev/null +++ b/apps/web/e2e/tests/pr/pr-open-shortcut.spec.ts @@ -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 { + 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 { + 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: "github stub" }), + ); + + 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: "github stub" }), + ); + + 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", + }); + }); +}); diff --git a/apps/web/lib/keyboard/constants.ts b/apps/web/lib/keyboard/constants.ts index 7936f3b104..5981db326c 100644 --- a/apps/web/lib/keyboard/constants.ts +++ b/apps/web/lib/keyboard/constants.ts @@ -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; diff --git a/apps/web/lib/keyboard/shortcut-overrides.test.ts b/apps/web/lib/keyboard/shortcut-overrides.test.ts index 56bf7d31d1..2285aa9689 100644 --- a/apps/web/lib/keyboard/shortcut-overrides.test.ts +++ b/apps/web/lib/keyboard/shortcut-overrides.test.ts @@ -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); }); }); diff --git a/apps/web/lib/keyboard/shortcut-overrides.ts b/apps/web/lib/keyboard/shortcut-overrides.ts index 4245de85cc..2fcc3be325 100644 --- a/apps/web/lib/keyboard/shortcut-overrides.ts +++ b/apps/web/lib/keyboard/shortcut-overrides.ts @@ -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(