feat(github): configurable quick-action presets and PR branch checkout (#689)

* feat(github): configurable quick-action presets and PR branch checkout

- Add workspace-scoped action presets (icon, label, hint, prompt template) for PRs and Issues, editable from GitHub settings with reset-to-defaults.
- When starting a task from a PR, check out the PR head branch directly in the worktree instead of branching off main.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(github): extract action-preset service to keep service.go under line limit

Splits the preset CRUD methods out of the oversized service.go and applies prettier formatting to three frontend files flagged by CI.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(github): address review feedback on action presets

- Prevent infinite refetch loop when fetching presets fails by tracking attempted workspaces in a ref instead of re-entering the effect on every loading-state change.
- Drop unused savePR/saveIssue helpers from the hook; only save() is consumed.
- Reorder the init spread in updateGitHubActionPresets/resetGitHubActionPresets so callers can't accidentally overwrite method/body.
- Tighten the GitHubActionPreset.icon type with string & {} so known icon keys keep autocomplete.
- Sharpen the eye-icon test to assert the exact IconEye mapping.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(github): address second round of review feedback

- Seed preset editor drafts from current store state via lazy initializers so a warm mount shows the user's saved customisations instead of silently reverting to defaults when Save is clicked.
- Guard Reset button with its own in-flight state to prevent double-firing.
- Stop freezing resolved defaults into the stored row on a partial update; persist only what the store has for the untouched kind so future default improvements still reach those workspaces.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(github): tighten quick-actions editor UI

Replace the stacked PR/Issue editors and multi-line preset cards with a tabbed layout and single-line rows. Each row shows icon · label · hint inline; an "Edit prompt" toggle reveals the template textarea only when needed. Drops the redundant section headings, up/down reorder buttons, and verbose template helper label (hint moved into the textarea placeholder).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(github): widen icon select trigger so the icon is visible

The icon select trigger had a fixed 40px width which the SelectTrigger's built-in chevron, padding, and icon gap all competed for, clipping the icon out of view. Drop the width override so the trigger sizes to its content.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
João Salavisa
2026-04-24 14:10:59 +01:00
committed by GitHub
parent 44dec297e1
commit ccc62d9324
25 changed files with 1264 additions and 76 deletions
@@ -0,0 +1,94 @@
import { describe, it, expect } from "vitest";
import {
DEFAULT_ISSUE_PRESETS,
DEFAULT_PR_PRESETS,
iconForPresetKey,
interpolatePromptTemplate,
resolveIssuePresets,
resolvePRPresets,
toTaskPreset,
} from "./action-presets";
import { IconEye, IconSparkles } from "@tabler/icons-react";
describe("interpolatePromptTemplate", () => {
it("replaces {url} and {title} placeholders", () => {
const out = interpolatePromptTemplate("Review {url} titled {title}", {
url: "https://gh/pr/1",
title: "Fix bug",
});
expect(out).toBe("Review https://gh/pr/1 titled Fix bug");
});
it("leaves unknown placeholders intact", () => {
const out = interpolatePromptTemplate("See {url} and {unknown}", {
url: "u",
title: "t",
});
expect(out).toBe("See u and {unknown}");
});
it("handles a template with no placeholders", () => {
expect(interpolatePromptTemplate("nothing to sub", { url: "u", title: "t" })).toBe(
"nothing to sub",
);
});
});
describe("iconForPresetKey", () => {
it("returns the mapped Tabler icon for a known key", () => {
expect(iconForPresetKey("eye")).toBe(IconEye);
});
it("falls back to sparkle for unknown or missing keys", () => {
expect(iconForPresetKey(undefined)).toBe(IconSparkles);
expect(iconForPresetKey("nonsense")).toBe(IconSparkles);
});
});
describe("resolvePRPresets / resolveIssuePresets", () => {
it("uses defaults when stored is null", () => {
const pr = resolvePRPresets(null);
expect(pr.length).toBe(DEFAULT_PR_PRESETS.length);
const issue = resolveIssuePresets(null);
expect(issue.length).toBe(DEFAULT_ISSUE_PRESETS.length);
});
it("uses defaults when the stored list is empty (treats as unset)", () => {
const pr = resolvePRPresets({ workspace_id: "w", pr: [], issue: [] });
expect(pr.length).toBe(DEFAULT_PR_PRESETS.length);
});
it("uses stored presets when non-empty", () => {
const stored = {
workspace_id: "w",
pr: [
{
id: "custom",
label: "Custom",
hint: "h",
icon: "eye",
prompt_template: "Do it on {url}",
},
],
issue: [],
};
const pr = resolvePRPresets(stored);
expect(pr).toHaveLength(1);
expect(pr[0].label).toBe("Custom");
expect(pr[0].prompt({ url: "https://x", title: "t" })).toBe("Do it on https://x");
});
});
describe("toTaskPreset", () => {
it("wires the prompt function to interpolate the stored template", () => {
const stored = {
id: "id",
label: "L",
hint: "H",
icon: "message",
prompt_template: "On {url} for {title}",
};
const preset = toTaskPreset(stored);
expect(preset.prompt({ url: "U", title: "T" })).toBe("On U for T");
});
});
@@ -0,0 +1,126 @@
import {
IconEye,
IconMessageDots,
IconTool,
IconCode,
IconSearch,
IconBug,
IconSparkles,
IconChecks,
} from "@tabler/icons-react";
import type { Icon } from "@tabler/icons-react";
import type {
GitHubActionPreset,
GitHubActionPresetIcon,
GitHubActionPresets,
} from "@/lib/types/github";
import type { TaskPreset } from "./quick-task-launcher";
export const PRESET_ICON_CHOICES: { key: GitHubActionPresetIcon; icon: Icon; label: string }[] = [
{ key: "eye", icon: IconEye, label: "Eye" },
{ key: "message", icon: IconMessageDots, label: "Message" },
{ key: "tool", icon: IconTool, label: "Tool" },
{ key: "code", icon: IconCode, label: "Code" },
{ key: "search", icon: IconSearch, label: "Search" },
{ key: "bug", icon: IconBug, label: "Bug" },
{ key: "sparkle", icon: IconSparkles, label: "Sparkle" },
{ key: "check", icon: IconChecks, label: "Check" },
];
const ICON_BY_KEY: Record<string, Icon> = Object.fromEntries(
PRESET_ICON_CHOICES.map((choice) => [choice.key, choice.icon]),
);
export function iconForPresetKey(key: string | undefined): Icon {
if (!key) return IconSparkles;
return ICON_BY_KEY[key] ?? IconSparkles;
}
// Interpolate `{url}` and `{title}` placeholders in a prompt template.
// Unknown placeholders are left untouched so the user sees what's broken.
export function interpolatePromptTemplate(
template: string,
opts: { url: string; title: string },
): string {
return template.replace(/\{(url|title)\}/g, (_match, key) => {
if (key === "url") return opts.url;
if (key === "title") return opts.title;
return `{${key}}`;
});
}
export function toTaskPreset(stored: GitHubActionPreset): TaskPreset {
return {
id: stored.id,
label: stored.label,
hint: stored.hint,
icon: iconForPresetKey(stored.icon),
prompt: (opts) => interpolatePromptTemplate(stored.prompt_template, opts),
};
}
export const DEFAULT_PR_PRESETS: GitHubActionPreset[] = [
{
id: "review",
label: "Review",
hint: "Read the diff, flag issues",
icon: "eye",
prompt_template:
"Review the pull request at {url}. Provide feedback on code quality, correctness, and suggest improvements.",
},
{
id: "address_feedback",
label: "Address feedback",
hint: "Apply review comments",
icon: "message",
prompt_template:
"Address the review feedback on the pull request at {url}. Make the requested changes and push them.",
},
{
id: "fix_ci",
label: "Fix CI",
hint: "Diagnose failing checks",
icon: "tool",
prompt_template:
"Investigate and fix the CI failures on the pull request at {url}. Run the failing checks locally, diagnose, and push fixes.",
},
];
export const DEFAULT_ISSUE_PRESETS: GitHubActionPreset[] = [
{
id: "implement",
label: "Implement",
hint: "Build and open a PR",
icon: "code",
prompt_template:
'Implement the changes described in the GitHub issue at {url} (title: "{title}"). Open a pull request when complete.',
},
{
id: "investigate",
label: "Investigate",
hint: "Find the root cause",
icon: "search",
prompt_template:
'Investigate the GitHub issue at {url} (title: "{title}"). Identify root cause and summarize findings.',
},
{
id: "reproduce",
label: "Reproduce",
hint: "Document repro steps",
icon: "bug",
prompt_template:
'Reproduce the bug described in the GitHub issue at {url} (title: "{title}"). Document the reproduction steps.',
},
];
// Resolve stored presets into runtime TaskPreset[]. Falls back to defaults while
// presets load so the /github page doesn't flicker empty dropdowns on first mount.
export function resolvePRPresets(stored: GitHubActionPresets | null): TaskPreset[] {
const source = stored?.pr?.length ? stored.pr : DEFAULT_PR_PRESETS;
return source.map(toTaskPreset);
}
export function resolveIssuePresets(stored: GitHubActionPresets | null): TaskPreset[] {
const source = stored?.issue?.length ? stored.issue : DEFAULT_ISSUE_PRESETS;
return source.map(toTaskPreset);
}
@@ -14,20 +14,23 @@ import { Spinner } from "@kandev/ui/spinner";
import { cn } from "@/lib/utils";
import { formatRelativeTime } from "@/lib/utils";
import type { GitHubIssue } from "@/lib/types/github";
import { ISSUE_TASK_PRESETS, type LaunchPayload, type TaskPreset } from "./quick-task-launcher";
import type { LaunchPayload, TaskPreset } from "./quick-task-launcher";
type IssueListProps = {
items: GitHubIssue[];
loading: boolean;
error: string | null;
presets: TaskPreset[];
onStartTask: (payload: LaunchPayload) => void;
};
function StartTaskMenu({
issue,
presets,
onStartTask,
}: {
issue: GitHubIssue;
presets: TaskPreset[];
onStartTask: IssueListProps["onStartTask"];
}) {
const launch = (preset: TaskPreset) => onStartTask({ kind: "issue", issue, preset });
@@ -41,7 +44,7 @@ function StartTaskMenu({
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
{ISSUE_TASK_PRESETS.map((p) => {
{presets.map((p) => {
const ItemIcon = p.icon;
return (
<DropdownMenuItem
@@ -80,9 +83,11 @@ function IssueLabels({ labels }: { labels: string[] }) {
function IssueRow({
issue,
presets,
onStartTask,
}: {
issue: GitHubIssue;
presets: TaskPreset[];
onStartTask: IssueListProps["onStartTask"];
}) {
const StateIcon = issue.state === "open" ? IconCircle : IconCircleCheck;
@@ -114,13 +119,13 @@ function IssueRow({
</div>
</div>
<div className="shrink-0">
<StartTaskMenu issue={issue} onStartTask={onStartTask} />
<StartTaskMenu issue={issue} presets={presets} onStartTask={onStartTask} />
</div>
</div>
);
}
function IssueListBody({ loading, error, items, onStartTask }: IssueListProps) {
function IssueListBody({ loading, error, items, presets, onStartTask }: IssueListProps) {
if (loading) {
return (
<div className="flex justify-center py-10">
@@ -144,6 +149,7 @@ function IssueListBody({ loading, error, items, onStartTask }: IssueListProps) {
<IssueRow
key={`${issue.repo_owner}/${issue.repo_name}#${issue.number}`}
issue={issue}
presets={presets}
onStartTask={onStartTask}
/>
))}
@@ -19,7 +19,7 @@ import {
import { Spinner } from "@kandev/ui/spinner";
import { cn, formatRelativeTime } from "@/lib/utils";
import type { GitHubPR, GitHubPRStatus } from "@/lib/types/github";
import { PR_TASK_PRESETS, type LaunchPayload, type TaskPreset } from "./quick-task-launcher";
import type { LaunchPayload, TaskPreset } from "./quick-task-launcher";
import { PRStatusBadges } from "./pr-status-badges";
import { prStatusKey, usePRStatuses } from "./use-pr-statuses";
@@ -27,6 +27,7 @@ type PRListProps = {
items: GitHubPR[];
loading: boolean;
error: string | null;
presets: TaskPreset[];
onStartTask: (payload: LaunchPayload) => void;
};
@@ -41,9 +42,11 @@ function prStateIcon(pr: GitHubPR): { Icon: Icon; className: string } {
function StartTaskMenu({
pr,
presets,
onStartTask,
}: {
pr: GitHubPR;
presets: TaskPreset[];
onStartTask: PRListProps["onStartTask"];
}) {
const launch = (preset: TaskPreset) => onStartTask({ kind: "pr", pr, preset });
@@ -57,7 +60,7 @@ function StartTaskMenu({
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
{PR_TASK_PRESETS.map((p) => {
{presets.map((p) => {
const ItemIcon = p.icon;
return (
<DropdownMenuItem
@@ -81,10 +84,12 @@ function StartTaskMenu({
function PRRow({
pr,
status,
presets,
onStartTask,
}: {
pr: GitHubPR;
status: GitHubPRStatus | null | undefined;
presets: TaskPreset[];
onStartTask: PRListProps["onStartTask"];
}) {
const { Icon: StateIcon, className: stateIconClass } = prStateIcon(pr);
@@ -112,13 +117,13 @@ function PRRow({
</div>
</div>
<div className="shrink-0">
<StartTaskMenu pr={pr} onStartTask={onStartTask} />
<StartTaskMenu pr={pr} presets={presets} onStartTask={onStartTask} />
</div>
</div>
);
}
function PRListBody({ loading, error, items, onStartTask }: PRListProps) {
function PRListBody({ loading, error, items, presets, onStartTask }: PRListProps) {
const statuses = usePRStatuses(items);
if (loading) {
return (
@@ -141,7 +146,15 @@ function PRListBody({ loading, error, items, onStartTask }: PRListProps) {
<div className="divide-y">
{items.map((pr) => {
const key = prStatusKey(pr.repo_owner, pr.repo_name, pr.number);
return <PRRow key={key} pr={pr} status={statuses.get(key)} onStartTask={onStartTask} />;
return (
<PRRow
key={key}
pr={pr}
status={statuses.get(key)}
presets={presets}
onStartTask={onStartTask}
/>
);
})}
</div>
);
@@ -2,14 +2,6 @@
import { useMemo } from "react";
import { useRouter } from "next/navigation";
import {
IconEye,
IconMessageDots,
IconTool,
IconCode,
IconSearch,
IconBug,
} from "@tabler/icons-react";
import type { Icon } from "@tabler/icons-react";
import { TaskCreateDialog } from "@/components/task-create-dialog";
import type { Repository, Task, Workflow, WorkflowStep } from "@/lib/types/http";
@@ -27,65 +19,12 @@ export type LaunchPayload =
| { kind: "pr"; pr: GitHubPR; preset: TaskPreset }
| { kind: "issue"; issue: GitHubIssue; preset: TaskPreset };
export const PR_TASK_PRESETS: TaskPreset[] = [
{
id: "review",
label: "Review",
hint: "Read the diff, flag issues",
icon: IconEye,
prompt: ({ url }) =>
`Review the pull request at ${url}. Provide feedback on code quality, correctness, and suggest improvements.`,
},
{
id: "address_feedback",
label: "Address feedback",
hint: "Apply review comments",
icon: IconMessageDots,
prompt: ({ url }) =>
`Address the review feedback on the pull request at ${url}. Make the requested changes and push them.`,
},
{
id: "fix_ci",
label: "Fix CI",
hint: "Diagnose failing checks",
icon: IconTool,
prompt: ({ url }) =>
`Investigate and fix the CI failures on the pull request at ${url}. Run the failing checks locally, diagnose, and push fixes.`,
},
];
export const ISSUE_TASK_PRESETS: TaskPreset[] = [
{
id: "implement",
label: "Implement",
hint: "Build and open a PR",
icon: IconCode,
prompt: ({ url, title }) =>
`Implement the changes described in the GitHub issue at ${url} (title: "${title}"). Open a pull request when complete.`,
},
{
id: "investigate",
label: "Investigate",
hint: "Find the root cause",
icon: IconSearch,
prompt: ({ url, title }) =>
`Investigate the GitHub issue at ${url} (title: "${title}"). Identify root cause and summarize findings.`,
},
{
id: "reproduce",
label: "Reproduce",
hint: "Document repro steps",
icon: IconBug,
prompt: ({ url, title }) =>
`Reproduce the bug described in the GitHub issue at ${url} (title: "${title}"). Document the reproduction steps.`,
},
];
type DialogState = {
title: string;
description: string;
repositoryId?: string;
branch?: string;
checkoutBranch?: string;
githubUrl?: string;
};
@@ -121,14 +60,22 @@ function buildDialogState(payload: LaunchPayload, repositories: Repository[]): D
const repo = matchRepo(repositories, data.owner, data.name);
const description = payload.preset.prompt({ url: data.url, title: data.title });
const title = `${payload.preset.label}: ${data.title}`;
const checkoutBranch = payload.kind === "pr" ? data.branch : undefined;
if (repo) {
return { title, description, repositoryId: repo.id, branch: data.branch };
return {
title,
description,
repositoryId: repo.id,
branch: data.branch,
checkoutBranch,
};
}
return {
title,
description,
githubUrl: `github.com/${data.owner}/${data.name}`,
branch: data.branch,
checkoutBranch,
};
}
@@ -194,6 +141,7 @@ export function QuickTaskLauncher({
description: dialog.description,
repositoryId: dialog.repositoryId,
branch: dialog.branch,
checkoutBranch: dialog.checkoutBranch,
githubUrl: dialog.githubUrl,
}}
onSuccess={handleSuccess}