Files
kandev/apps/web/components/github/action-presets-section.tsx
T
João Salavisa ccc62d9324 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>
2026-04-24 14:10:59 +01:00

299 lines
9.2 KiB
TypeScript

"use client";
import { createElement, useMemo, useState, useCallback } from "react";
import { IconPlus, IconTrash, IconRefresh } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import { Input } from "@kandev/ui/input";
import { Textarea } from "@kandev/ui/textarea";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@kandev/ui/tabs";
import { useToast } from "@/components/toast-provider";
import { SettingsSection } from "@/components/settings/settings-section";
import { useGitHubActionPresets } from "@/hooks/domains/github/use-github-action-presets";
import {
DEFAULT_ISSUE_PRESETS,
DEFAULT_PR_PRESETS,
PRESET_ICON_CHOICES,
iconForPresetKey,
} from "@/components/github/my-github/action-presets";
import type { GitHubActionPreset } from "@/lib/types/github";
function newPreset(): GitHubActionPreset {
return {
id: `preset_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`,
label: "New action",
hint: "",
icon: "sparkle",
prompt_template: "",
};
}
function PresetIconSelect({ value, onChange }: { value: string; onChange: (v: string) => void }) {
return (
<Select value={value} onValueChange={onChange}>
<SelectTrigger className="h-8 cursor-pointer" aria-label="Icon">
<SelectValue>
{createElement(iconForPresetKey(value), { className: "h-4 w-4" })}
</SelectValue>
</SelectTrigger>
<SelectContent>
{PRESET_ICON_CHOICES.map((choice) => {
const ChoiceIcon = choice.icon;
return (
<SelectItem key={choice.key} value={choice.key} className="cursor-pointer">
<span className="flex items-center gap-2">
<ChoiceIcon className="h-3.5 w-3.5" />
{choice.label}
</span>
</SelectItem>
);
})}
</SelectContent>
</Select>
);
}
function PresetRow({
preset,
expanded,
onToggle,
onPatch,
onRemove,
}: {
preset: GitHubActionPreset;
expanded: boolean;
onToggle: () => void;
onPatch: (patch: Partial<GitHubActionPreset>) => void;
onRemove: () => void;
}) {
return (
<div className="rounded-md border">
<div className="flex items-center gap-2 p-2">
<PresetIconSelect value={preset.icon} onChange={(v) => onPatch({ icon: v })} />
<Input
className="h-8 w-40"
value={preset.label}
placeholder="Label"
onChange={(e) => onPatch({ label: e.target.value })}
/>
<Input
className="h-8 flex-1"
value={preset.hint}
placeholder="Hint (optional)"
onChange={(e) => onPatch({ hint: e.target.value })}
/>
<Button variant="ghost" size="sm" className="h-8 cursor-pointer text-xs" onClick={onToggle}>
{expanded ? "Hide prompt" : "Edit prompt"}
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 cursor-pointer text-destructive"
onClick={onRemove}
aria-label="Remove"
>
<IconTrash className="h-3.5 w-3.5" />
</Button>
</div>
{expanded && (
<div className="px-2 pb-2">
<Textarea
rows={3}
className="text-xs font-mono"
placeholder="Prompt template — {url} and {title} are substituted"
value={preset.prompt_template}
onChange={(e) => onPatch({ prompt_template: e.target.value })}
/>
</div>
)}
</div>
);
}
function PresetEditor({
presets,
onChange,
addLabel,
}: {
presets: GitHubActionPreset[];
onChange: (presets: GitHubActionPreset[]) => void;
addLabel: string;
}) {
const [expandedId, setExpandedId] = useState<string | null>(null);
const patch = useCallback(
(index: number, change: Partial<GitHubActionPreset>) => {
onChange(presets.map((p, i) => (i === index ? { ...p, ...change } : p)));
},
[presets, onChange],
);
const remove = useCallback(
(index: number) => {
onChange(presets.filter((_, i) => i !== index));
},
[presets, onChange],
);
const add = useCallback(() => {
const created = newPreset();
onChange([...presets, created]);
setExpandedId(created.id);
}, [presets, onChange]);
return (
<div className="space-y-2">
{presets.map((preset, index) => (
<PresetRow
key={preset.id}
preset={preset}
expanded={expandedId === preset.id}
onToggle={() => setExpandedId((id) => (id === preset.id ? null : preset.id))}
onPatch={(p) => patch(index, p)}
onRemove={() => remove(index)}
/>
))}
<Button size="sm" variant="outline" onClick={add} className="cursor-pointer">
<IconPlus className="h-3.5 w-3.5 mr-1" />
{addLabel}
</Button>
</div>
);
}
function usePresetDrafts(workspaceId: string): {
prDraft: GitHubActionPreset[];
issueDraft: GitHubActionPreset[];
setPrDraft: (next: GitHubActionPreset[]) => void;
setIssueDraft: (next: GitHubActionPreset[]) => void;
dirty: boolean;
save: () => Promise<void>;
reset: () => Promise<void>;
loading: boolean;
} {
const { presets, save, reset, loading } = useGitHubActionPresets(workspaceId);
const [prDraft, setPrDraft] = useState<GitHubActionPreset[]>(() =>
presets?.pr?.length ? presets.pr : DEFAULT_PR_PRESETS,
);
const [issueDraft, setIssueDraft] = useState<GitHubActionPreset[]>(() =>
presets?.issue?.length ? presets.issue : DEFAULT_ISSUE_PRESETS,
);
// Render-time conditional setState is React's documented "adjust state
// during render" pattern; it resets drafts whenever a new server response
// replaces the presets reference.
const [syncedPresets, setSyncedPresets] = useState(presets);
if (presets && presets !== syncedPresets) {
setSyncedPresets(presets);
setPrDraft(presets.pr?.length ? presets.pr : DEFAULT_PR_PRESETS);
setIssueDraft(presets.issue?.length ? presets.issue : DEFAULT_ISSUE_PRESETS);
}
const dirty = useMemo(() => {
const currentPR = presets?.pr ?? DEFAULT_PR_PRESETS;
const currentIssue = presets?.issue ?? DEFAULT_ISSUE_PRESETS;
return (
JSON.stringify(currentPR) !== JSON.stringify(prDraft) ||
JSON.stringify(currentIssue) !== JSON.stringify(issueDraft)
);
}, [presets, prDraft, issueDraft]);
const persist = useCallback(async () => {
await save({ pr: prDraft, issue: issueDraft });
}, [save, prDraft, issueDraft]);
const doReset = useCallback(async () => {
const response = await reset();
if (response) {
setPrDraft(response.pr?.length ? response.pr : DEFAULT_PR_PRESETS);
setIssueDraft(response.issue?.length ? response.issue : DEFAULT_ISSUE_PRESETS);
}
}, [reset]);
return {
prDraft,
issueDraft,
setPrDraft,
setIssueDraft,
dirty,
save: persist,
reset: doReset,
loading,
};
}
export function ActionPresetsSection({ workspaceId }: { workspaceId: string }) {
const { toast } = useToast();
const { prDraft, issueDraft, setPrDraft, setIssueDraft, dirty, save, reset, loading } =
usePresetDrafts(workspaceId);
const [saving, setSaving] = useState(false);
const [resetting, setResetting] = useState(false);
const handleSave = async () => {
setSaving(true);
try {
await save();
toast({ description: "Quick actions saved", variant: "success" });
} catch {
toast({ description: "Failed to save quick actions", variant: "error" });
} finally {
setSaving(false);
}
};
const handleReset = async () => {
setResetting(true);
try {
await reset();
toast({ description: "Quick actions reset to defaults", variant: "success" });
} catch {
toast({ description: "Failed to reset quick actions", variant: "error" });
} finally {
setResetting(false);
}
};
return (
<SettingsSection
title="Quick actions"
description="Prompts shown on /github when starting a task from a PR or issue."
action={
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
onClick={handleReset}
disabled={loading || saving || resetting}
className="cursor-pointer"
>
<IconRefresh className="h-3.5 w-3.5 mr-1" />
Reset
</Button>
<Button
size="sm"
onClick={handleSave}
disabled={!dirty || saving}
className="cursor-pointer"
>
Save changes
</Button>
</div>
}
>
<Tabs defaultValue="pr">
<TabsList>
<TabsTrigger value="pr" className="cursor-pointer">
Pull requests
</TabsTrigger>
<TabsTrigger value="issue" className="cursor-pointer">
Issues
</TabsTrigger>
</TabsList>
<TabsContent value="pr">
<PresetEditor presets={prDraft} onChange={setPrDraft} addLabel="Add PR action" />
</TabsContent>
<TabsContent value="issue">
<PresetEditor presets={issueDraft} onChange={setIssueDraft} addLabel="Add issue action" />
</TabsContent>
</Tabs>
</SettingsSection>
);
}