ccc62d9324
* 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>
380 lines
12 KiB
TypeScript
380 lines
12 KiB
TypeScript
"use client";
|
|
|
|
import Link from "next/link";
|
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
import { IconArrowLeft, IconBrandGithub } from "@tabler/icons-react";
|
|
import { Alert, AlertDescription } from "@kandev/ui/alert";
|
|
import { useGitHubStatus } from "@/hooks/domains/github/use-github-status";
|
|
import type { Repository, Workflow, WorkflowStep } from "@/lib/types/http";
|
|
import type { GitHubIssue, GitHubPR } from "@/lib/types/github";
|
|
import { PRList } from "@/components/github/my-github/pr-list";
|
|
import { IssueList } from "@/components/github/my-github/issue-list";
|
|
import {
|
|
PresetsSidebar,
|
|
type SidebarSelection,
|
|
} from "@/components/github/my-github/presets-sidebar";
|
|
import { PR_PRESETS, ISSUE_PRESETS } from "@/components/github/my-github/search-bar";
|
|
import { useGitHubSearch } from "@/components/github/my-github/use-github-search";
|
|
import { useSavedPresets, type SavedPreset } from "@/components/github/my-github/use-saved-presets";
|
|
import { useKnownRepos, resetKnownReposStore } from "@/components/github/my-github/use-known-repos";
|
|
import { useCommittedQuery } from "@/components/github/my-github/use-committed-query";
|
|
import { ListToolbar } from "@/components/github/my-github/list-toolbar";
|
|
import { ResultsPagination } from "@/components/github/my-github/results-pagination";
|
|
import { SavePresetDialog } from "@/components/github/my-github/save-preset-dialog";
|
|
import {
|
|
QuickTaskLauncher,
|
|
type LaunchPayload,
|
|
type TaskPreset,
|
|
} from "@/components/github/my-github/quick-task-launcher";
|
|
import {
|
|
resolvePRPresets,
|
|
resolveIssuePresets,
|
|
} from "@/components/github/my-github/action-presets";
|
|
import { useGitHubActionPresets } from "@/hooks/domains/github/use-github-action-presets";
|
|
|
|
type GitHubPageClientProps = {
|
|
workspaceId?: string;
|
|
workflows: Workflow[];
|
|
steps: WorkflowStep[];
|
|
repositories: Repository[];
|
|
};
|
|
|
|
function PageHeader() {
|
|
return (
|
|
<header className="flex items-center gap-3 px-4 py-3 border-b shrink-0">
|
|
<Link href="/" className="text-muted-foreground hover:text-foreground cursor-pointer">
|
|
<IconArrowLeft className="h-4 w-4" />
|
|
</Link>
|
|
<IconBrandGithub className="h-5 w-5" />
|
|
<h1 className="text-lg font-semibold">My GitHub</h1>
|
|
<span className="text-xs text-muted-foreground ml-2">
|
|
Pull requests and issues across your repos.
|
|
</span>
|
|
</header>
|
|
);
|
|
}
|
|
|
|
function NotAuthenticatedNotice() {
|
|
return (
|
|
<Alert>
|
|
<AlertDescription>
|
|
GitHub is not connected. Configure GitHub authentication (gh CLI or a Personal Access Token)
|
|
in{" "}
|
|
<Link href="/settings" className="underline font-medium">
|
|
Settings → GitHub
|
|
</Link>{" "}
|
|
to see your pull requests and issues.
|
|
</AlertDescription>
|
|
</Alert>
|
|
);
|
|
}
|
|
|
|
function NoWorkspaceNotice() {
|
|
return (
|
|
<div className="px-6 py-3 border-b shrink-0">
|
|
<Alert>
|
|
<AlertDescription>
|
|
No workspace configured. Create a workspace first to start tasks from PRs/issues.
|
|
</AlertDescription>
|
|
</Alert>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function resolveTitle(selection: SidebarSelection, saved: SavedPreset[]): string {
|
|
if (selection.source === "saved") {
|
|
return saved.find((p) => p.id === selection.id)?.label ?? "Saved query";
|
|
}
|
|
const presets = selection.kind === "pr" ? PR_PRESETS : ISSUE_PRESETS;
|
|
return (
|
|
presets.find((p) => p.value === selection.id)?.label ??
|
|
(selection.kind === "pr" ? "Pull requests" : "Issues")
|
|
);
|
|
}
|
|
|
|
function ResultsList({
|
|
selection,
|
|
items,
|
|
loading,
|
|
error,
|
|
prPresets,
|
|
issuePresets,
|
|
onStartTask,
|
|
}: {
|
|
selection: SidebarSelection;
|
|
items: Array<GitHubPR | GitHubIssue>;
|
|
loading: boolean;
|
|
error: string | null;
|
|
prPresets: TaskPreset[];
|
|
issuePresets: TaskPreset[];
|
|
onStartTask: (payload: LaunchPayload) => void;
|
|
}) {
|
|
if (selection.kind === "pr") {
|
|
return (
|
|
<PRList
|
|
items={items as GitHubPR[]}
|
|
loading={loading}
|
|
error={error}
|
|
presets={prPresets}
|
|
onStartTask={onStartTask}
|
|
/>
|
|
);
|
|
}
|
|
return (
|
|
<IssueList
|
|
items={items as GitHubIssue[]}
|
|
loading={loading}
|
|
error={error}
|
|
presets={issuePresets}
|
|
onStartTask={onStartTask}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function defaultSelection(kind: "pr" | "issue"): SidebarSelection {
|
|
const presets = kind === "pr" ? PR_PRESETS : ISSUE_PRESETS;
|
|
return { kind, source: "preset", id: presets[0]?.value ?? "" };
|
|
}
|
|
|
|
function defaultQuery(kind: "pr" | "issue"): string {
|
|
const presets = kind === "pr" ? PR_PRESETS : ISSUE_PRESETS;
|
|
return presets[0]?.filter ?? "";
|
|
}
|
|
|
|
type GitHubPageState = ReturnType<typeof useGitHubPageState>;
|
|
|
|
function useRepoOptions(
|
|
selection: SidebarSelection,
|
|
committedQuery: string,
|
|
items: Array<GitHubPR | GitHubIssue>,
|
|
repoFilter: string,
|
|
): string[] {
|
|
const pageRepos = useMemo(
|
|
() =>
|
|
items
|
|
.filter((it) => it.repo_owner && it.repo_name)
|
|
.map((it) => `${it.repo_owner}/${it.repo_name}`),
|
|
[items],
|
|
);
|
|
// Reset the accumulator whenever the query context changes (preset, saved,
|
|
// custom query). Repo filter is deliberately excluded so narrowing doesn't
|
|
// reset — that's the whole point of the accumulator.
|
|
const reposResetKey = `${selection.kind}:${selection.source}:${selection.id}:${committedQuery.trim()}`;
|
|
const knownRepos = useKnownRepos(reposResetKey, pageRepos);
|
|
return useMemo(() => {
|
|
const set = new Set(knownRepos);
|
|
if (repoFilter) set.add(repoFilter);
|
|
return Array.from(set).sort();
|
|
}, [knownRepos, repoFilter]);
|
|
}
|
|
|
|
function useGitHubPageState() {
|
|
const [selection, setSelection] = useState<SidebarSelection>(() => defaultSelection("pr"));
|
|
const {
|
|
draft: customQuery,
|
|
committed: committedQuery,
|
|
setDraft: setCustomQuery,
|
|
setImmediate: setQueryImmediate,
|
|
commit: commitCustomQuery,
|
|
} = useCommittedQuery(defaultQuery("pr"));
|
|
const [repoFilter, setRepoFilter] = useState("");
|
|
const [saveDialogOpen, setSaveDialogOpen] = useState(false);
|
|
const {
|
|
presets: savedPresets,
|
|
save: saveSavedPreset,
|
|
remove: removeSavedPreset,
|
|
} = useSavedPresets();
|
|
|
|
const presets = selection.kind === "pr" ? PR_PRESETS : ISSUE_PRESETS;
|
|
const search = useGitHubSearch<GitHubPR | GitHubIssue>(
|
|
selection.kind,
|
|
presets,
|
|
selection.source === "preset" ? selection.id : "",
|
|
committedQuery,
|
|
repoFilter,
|
|
);
|
|
const repoOptions = useRepoOptions(selection, committedQuery, search.items, repoFilter);
|
|
const title = useMemo(() => resolveTitle(selection, savedPresets), [selection, savedPresets]);
|
|
|
|
const onSelect = useCallback(
|
|
(s: SidebarSelection) => {
|
|
setSelection(s);
|
|
if (s.source === "saved") {
|
|
const found = savedPresets.find((p) => p.id === s.id);
|
|
setQueryImmediate(found?.customQuery ?? "");
|
|
setRepoFilter(found?.repoFilter ?? "");
|
|
return;
|
|
}
|
|
const presetList = s.kind === "pr" ? PR_PRESETS : ISSUE_PRESETS;
|
|
const preset = presetList.find((p) => p.value === s.id);
|
|
setQueryImmediate(preset?.filter ?? "");
|
|
setRepoFilter("");
|
|
},
|
|
[savedPresets, setQueryImmediate],
|
|
);
|
|
|
|
const canSaveCurrent = customQuery.trim().length > 0 || repoFilter.length > 0;
|
|
const suggestedLabel = customQuery.trim() || (repoFilter ? `In ${repoFilter}` : "Saved query");
|
|
const onOpenSaveDialog = () => {
|
|
if (canSaveCurrent) setSaveDialogOpen(true);
|
|
};
|
|
const onConfirmSave = (label: string) => {
|
|
const created = saveSavedPreset({ kind: selection.kind, label, customQuery, repoFilter });
|
|
setSelection({ kind: selection.kind, source: "saved", id: created.id });
|
|
};
|
|
const onDeleteSaved = (id: string) => {
|
|
removeSavedPreset(id);
|
|
if (selection.source === "saved" && selection.id === id) {
|
|
setSelection(defaultSelection(selection.kind));
|
|
setQueryImmediate(defaultQuery(selection.kind));
|
|
setRepoFilter("");
|
|
}
|
|
};
|
|
|
|
return {
|
|
selection,
|
|
customQuery,
|
|
committedQuery,
|
|
setCustomQuery,
|
|
commitCustomQuery,
|
|
repoFilter,
|
|
setRepoFilter,
|
|
savedPresets,
|
|
search,
|
|
repoOptions,
|
|
title,
|
|
onSelect,
|
|
canSaveCurrent,
|
|
suggestedLabel,
|
|
saveDialogOpen,
|
|
setSaveDialogOpen,
|
|
onOpenSaveDialog,
|
|
onConfirmSave,
|
|
onDeleteSaved,
|
|
};
|
|
}
|
|
|
|
function AuthenticatedLayout({
|
|
workspaceId,
|
|
state,
|
|
prPresets,
|
|
issuePresets,
|
|
onStartTask,
|
|
}: {
|
|
workspaceId: string | undefined;
|
|
state: GitHubPageState;
|
|
prPresets: TaskPreset[];
|
|
issuePresets: TaskPreset[];
|
|
onStartTask: (payload: LaunchPayload) => void;
|
|
}) {
|
|
const { selection, search, repoOptions, title } = state;
|
|
return (
|
|
<div className="flex-1 flex min-h-0">
|
|
<aside className="w-60 border-r overflow-y-auto shrink-0">
|
|
<PresetsSidebar
|
|
selected={selection}
|
|
onSelect={state.onSelect}
|
|
savedPresets={state.savedPresets}
|
|
onDeleteSaved={state.onDeleteSaved}
|
|
canSaveCurrent={state.canSaveCurrent}
|
|
onSaveCurrent={state.onOpenSaveDialog}
|
|
/>
|
|
</aside>
|
|
<main className="flex-1 flex flex-col min-w-0 overflow-hidden">
|
|
<ListToolbar
|
|
title={title}
|
|
count={search.total}
|
|
loading={search.loading}
|
|
lastFetchedAt={search.lastFetchedAt}
|
|
customQuery={state.customQuery}
|
|
committedQuery={state.committedQuery}
|
|
onCustomQueryChange={state.setCustomQuery}
|
|
onCommitCustomQuery={state.commitCustomQuery}
|
|
repoFilter={state.repoFilter}
|
|
onRepoFilterChange={state.setRepoFilter}
|
|
repoOptions={repoOptions}
|
|
onRefresh={search.refresh}
|
|
/>
|
|
{!workspaceId && <NoWorkspaceNotice />}
|
|
<div className="flex-1 overflow-auto px-6 py-4">
|
|
<ResultsList
|
|
selection={selection}
|
|
items={search.items}
|
|
loading={search.loading}
|
|
error={search.error}
|
|
prPresets={prPresets}
|
|
issuePresets={issuePresets}
|
|
onStartTask={onStartTask}
|
|
/>
|
|
</div>
|
|
<ResultsPagination
|
|
page={search.page}
|
|
pageSize={search.pageSize}
|
|
total={search.total}
|
|
onPageChange={search.setPage}
|
|
/>
|
|
</main>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function GitHubPageClient({
|
|
workspaceId,
|
|
workflows,
|
|
steps,
|
|
repositories,
|
|
}: GitHubPageClientProps) {
|
|
const { status, loaded } = useGitHubStatus();
|
|
const [launchPayload, setLaunchPayload] = useState<LaunchPayload | null>(null);
|
|
const state = useGitHubPageState();
|
|
const { presets: storedPresets } = useGitHubActionPresets(workspaceId ?? null);
|
|
const prPresets = useMemo(() => resolvePRPresets(storedPresets), [storedPresets]);
|
|
const issuePresets = useMemo(() => resolveIssuePresets(storedPresets), [storedPresets]);
|
|
|
|
// Drop the module-level repo accumulator on page unmount so a later visit
|
|
// doesn't inherit a stale set from the previous navigation.
|
|
useEffect(() => resetKnownReposStore, []);
|
|
|
|
const onStartTask = useCallback((payload: LaunchPayload) => setLaunchPayload(payload), []);
|
|
const onCloseLaunch = useCallback(() => setLaunchPayload(null), []);
|
|
const authed = !!status?.authenticated;
|
|
|
|
return (
|
|
<div className="h-screen w-full flex flex-col bg-background">
|
|
<PageHeader />
|
|
{!loaded && <div className="p-6 text-sm text-muted-foreground">Checking GitHub status…</div>}
|
|
{loaded && !authed && (
|
|
<div className="p-6 max-w-2xl">
|
|
<NotAuthenticatedNotice />
|
|
</div>
|
|
)}
|
|
{loaded && authed && (
|
|
<AuthenticatedLayout
|
|
workspaceId={workspaceId}
|
|
state={state}
|
|
prPresets={prPresets}
|
|
issuePresets={issuePresets}
|
|
onStartTask={onStartTask}
|
|
/>
|
|
)}
|
|
<QuickTaskLauncher
|
|
workspaceId={workspaceId ?? null}
|
|
workflows={workflows}
|
|
steps={steps}
|
|
repositories={repositories}
|
|
payload={launchPayload}
|
|
onClose={onCloseLaunch}
|
|
/>
|
|
<SavePresetDialog
|
|
open={state.saveDialogOpen}
|
|
onOpenChange={state.setSaveDialogOpen}
|
|
kind={state.selection.kind}
|
|
customQuery={state.customQuery}
|
|
repoFilter={state.repoFilter}
|
|
suggestedLabel={state.suggestedLabel}
|
|
onSave={state.onConfirmSave}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|