fix: ui polish — unified topbar, selector consistency, quick actions editor improvements (#693)
This commit is contained in:
@@ -440,7 +440,7 @@ const (
|
||||
)
|
||||
|
||||
// ActionPreset is a single configurable quick-task launcher entry.
|
||||
// PromptTemplate supports `{url}` and `{title}` placeholders which are
|
||||
// PromptTemplate supports `{{url}}` and `{{title}}` placeholders which are
|
||||
// substituted client-side when the dialog is opened.
|
||||
type ActionPreset struct {
|
||||
ID string `json:"id"`
|
||||
@@ -474,21 +474,21 @@ func DefaultPRActionPresets() []ActionPreset {
|
||||
Label: "Review",
|
||||
Hint: "Read the diff, flag issues",
|
||||
Icon: "eye",
|
||||
PromptTemplate: "Review the pull request at {url}. Provide feedback on code quality, correctness, and suggest improvements.",
|
||||
PromptTemplate: "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",
|
||||
PromptTemplate: "Address the review feedback on the pull request at {url}. Make the requested changes and push them.",
|
||||
PromptTemplate: "Review the feedback on the pull request at {{url}}. Evaluate each comment critically — apply changes that improve the code, push back on suggestions that are unnecessary or harmful, and explain your reasoning. Push the changes when done.",
|
||||
},
|
||||
{
|
||||
ID: "fix_ci",
|
||||
Label: "Fix CI",
|
||||
Hint: "Diagnose failing checks",
|
||||
Icon: "tool",
|
||||
PromptTemplate: "Investigate and fix the CI failures on the pull request at {url}. Run the failing checks locally, diagnose, and push fixes.",
|
||||
PromptTemplate: "Investigate and fix the CI failures and merge conflicts on the pull request at {{url}}. Run the failing checks locally, resolve any conflicts, diagnose issues, and push fixes.",
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -502,21 +502,21 @@ func DefaultIssueActionPresets() []ActionPreset {
|
||||
Label: "Implement",
|
||||
Hint: "Build and open a PR",
|
||||
Icon: "code",
|
||||
PromptTemplate: `Implement the changes described in the GitHub issue at {url} (title: "{title}"). Open a pull request when complete.`,
|
||||
PromptTemplate: `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",
|
||||
PromptTemplate: `Investigate the GitHub issue at {url} (title: "{title}"). Identify root cause and summarize findings.`,
|
||||
PromptTemplate: `Investigate the GitHub issue at {{url}} (title: "{{title}}"). Identify root cause and summarize findings.`,
|
||||
},
|
||||
{
|
||||
ID: "reproduce",
|
||||
Label: "Reproduce",
|
||||
Hint: "Document repro steps",
|
||||
Icon: "bug",
|
||||
PromptTemplate: `Reproduce the bug described in the GitHub issue at {url} (title: "{title}"). Document the reproduction steps.`,
|
||||
PromptTemplate: `Reproduce the bug described in the GitHub issue at {{url}} (title: "{{title}}"). Document the reproduction steps.`,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,9 +3,8 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "./lib/utils";
|
||||
|
||||
interface ScrollOnOverflowProps {
|
||||
interface ScrollOnOverflowProps extends Omit<React.HTMLAttributes<HTMLSpanElement>, "children"> {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
/** Scroll speed in pixels per second. Default 60. */
|
||||
speed?: number;
|
||||
}
|
||||
@@ -16,7 +15,7 @@ interface ScrollOnOverflowProps {
|
||||
*/
|
||||
const ScrollOnOverflow = React.memo(
|
||||
React.forwardRef<HTMLSpanElement, ScrollOnOverflowProps>(function ScrollOnOverflow(
|
||||
{ children, className, speed = 60 },
|
||||
{ children, className, speed = 60, ...rest },
|
||||
ref,
|
||||
) {
|
||||
const outerRef = React.useRef<HTMLSpanElement>(null);
|
||||
@@ -73,6 +72,7 @@ const ScrollOnOverflow = React.memo(
|
||||
className={cn("inline-block min-w-0 overflow-hidden", className)}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
{...rest}
|
||||
>
|
||||
<span ref={innerRef} className="inline-block whitespace-nowrap">
|
||||
{children}
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { IconArrowLeft, IconBrandGithub } from "@tabler/icons-react";
|
||||
import { IconBrandGithub } from "@tabler/icons-react";
|
||||
import { Alert, AlertDescription } from "@kandev/ui/alert";
|
||||
import { PageTopbar } from "@/components/page-topbar";
|
||||
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";
|
||||
@@ -13,8 +14,16 @@ import {
|
||||
PresetsSidebar,
|
||||
type SidebarSelection,
|
||||
} from "@/components/github/my-github/presets-sidebar";
|
||||
import { PR_PRESETS, ISSUE_PRESETS } from "@/components/github/my-github/search-bar";
|
||||
import {
|
||||
PR_PRESETS,
|
||||
ISSUE_PRESETS,
|
||||
type PresetOption,
|
||||
} from "@/components/github/my-github/search-bar";
|
||||
import { useGitHubSearch } from "@/components/github/my-github/use-github-search";
|
||||
import {
|
||||
useDefaultQueryPresets,
|
||||
resolvePresetOptions,
|
||||
} from "@/components/github/my-github/use-default-query-presets";
|
||||
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";
|
||||
@@ -41,16 +50,11 @@ type GitHubPageClientProps = {
|
||||
|
||||
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>
|
||||
<PageTopbar
|
||||
title="GitHub"
|
||||
subtitle="Pull requests and issues across your repos."
|
||||
icon={<IconBrandGithub className="h-4 w-4" />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -81,11 +85,16 @@ function NoWorkspaceNotice() {
|
||||
);
|
||||
}
|
||||
|
||||
function resolveTitle(selection: SidebarSelection, saved: SavedPreset[]): string {
|
||||
function resolveTitle(
|
||||
selection: SidebarSelection,
|
||||
saved: SavedPreset[],
|
||||
prPresets: PresetOption[],
|
||||
issuePresets: PresetOption[],
|
||||
): 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;
|
||||
const presets = selection.kind === "pr" ? prPresets : issuePresets;
|
||||
return (
|
||||
presets.find((p) => p.value === selection.id)?.label ??
|
||||
(selection.kind === "pr" ? "Pull requests" : "Issues")
|
||||
@@ -131,16 +140,6 @@ function ResultsList({
|
||||
);
|
||||
}
|
||||
|
||||
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(
|
||||
@@ -168,15 +167,28 @@ function useRepoOptions(
|
||||
}, [knownRepos, repoFilter]);
|
||||
}
|
||||
|
||||
function useResolvedQueryPresets() {
|
||||
const { prPresets: storedPr, issuePresets: storedIssue } = useDefaultQueryPresets();
|
||||
const pr = useMemo(() => resolvePresetOptions(storedPr, PR_PRESETS), [storedPr]);
|
||||
const issue = useMemo(() => resolvePresetOptions(storedIssue, ISSUE_PRESETS), [storedIssue]);
|
||||
return { pr, issue };
|
||||
}
|
||||
|
||||
function useGitHubPageState() {
|
||||
const [selection, setSelection] = useState<SidebarSelection>(() => defaultSelection("pr"));
|
||||
const { pr: resolvedPrPresets, issue: resolvedIssuePresets } = useResolvedQueryPresets();
|
||||
|
||||
const [selection, setSelection] = useState<SidebarSelection>(() => ({
|
||||
kind: "pr",
|
||||
source: "preset",
|
||||
id: resolvedPrPresets[0]?.value ?? "",
|
||||
}));
|
||||
const {
|
||||
draft: customQuery,
|
||||
committed: committedQuery,
|
||||
setDraft: setCustomQuery,
|
||||
setImmediate: setQueryImmediate,
|
||||
commit: commitCustomQuery,
|
||||
} = useCommittedQuery(defaultQuery("pr"));
|
||||
} = useCommittedQuery(resolvedPrPresets[0]?.filter ?? "");
|
||||
const [repoFilter, setRepoFilter] = useState("");
|
||||
const [saveDialogOpen, setSaveDialogOpen] = useState(false);
|
||||
const {
|
||||
@@ -185,7 +197,7 @@ function useGitHubPageState() {
|
||||
remove: removeSavedPreset,
|
||||
} = useSavedPresets();
|
||||
|
||||
const presets = selection.kind === "pr" ? PR_PRESETS : ISSUE_PRESETS;
|
||||
const presets = selection.kind === "pr" ? resolvedPrPresets : resolvedIssuePresets;
|
||||
const search = useGitHubSearch<GitHubPR | GitHubIssue>(
|
||||
selection.kind,
|
||||
presets,
|
||||
@@ -194,7 +206,10 @@ function useGitHubPageState() {
|
||||
repoFilter,
|
||||
);
|
||||
const repoOptions = useRepoOptions(selection, committedQuery, search.items, repoFilter);
|
||||
const title = useMemo(() => resolveTitle(selection, savedPresets), [selection, savedPresets]);
|
||||
const title = useMemo(
|
||||
() => resolveTitle(selection, savedPresets, resolvedPrPresets, resolvedIssuePresets),
|
||||
[selection, savedPresets, resolvedPrPresets, resolvedIssuePresets],
|
||||
);
|
||||
|
||||
const onSelect = useCallback(
|
||||
(s: SidebarSelection) => {
|
||||
@@ -205,12 +220,12 @@ function useGitHubPageState() {
|
||||
setRepoFilter(found?.repoFilter ?? "");
|
||||
return;
|
||||
}
|
||||
const presetList = s.kind === "pr" ? PR_PRESETS : ISSUE_PRESETS;
|
||||
const presetList = s.kind === "pr" ? resolvedPrPresets : resolvedIssuePresets;
|
||||
const preset = presetList.find((p) => p.value === s.id);
|
||||
setQueryImmediate(preset?.filter ?? "");
|
||||
setRepoFilter("");
|
||||
},
|
||||
[savedPresets, setQueryImmediate],
|
||||
[savedPresets, setQueryImmediate, resolvedPrPresets, resolvedIssuePresets],
|
||||
);
|
||||
|
||||
const canSaveCurrent = customQuery.trim().length > 0 || repoFilter.length > 0;
|
||||
@@ -225,8 +240,9 @@ function useGitHubPageState() {
|
||||
const onDeleteSaved = (id: string) => {
|
||||
removeSavedPreset(id);
|
||||
if (selection.source === "saved" && selection.id === id) {
|
||||
setSelection(defaultSelection(selection.kind));
|
||||
setQueryImmediate(defaultQuery(selection.kind));
|
||||
const fallbackPresets = selection.kind === "pr" ? resolvedPrPresets : resolvedIssuePresets;
|
||||
setSelection({ kind: selection.kind, source: "preset", id: fallbackPresets[0]?.value ?? "" });
|
||||
setQueryImmediate(fallbackPresets[0]?.filter ?? "");
|
||||
setRepoFilter("");
|
||||
}
|
||||
};
|
||||
@@ -251,6 +267,8 @@ function useGitHubPageState() {
|
||||
onOpenSaveDialog,
|
||||
onConfirmSave,
|
||||
onDeleteSaved,
|
||||
resolvedPrPresets,
|
||||
resolvedIssuePresets,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -278,6 +296,8 @@ function AuthenticatedLayout({
|
||||
onDeleteSaved={state.onDeleteSaved}
|
||||
canSaveCurrent={state.canSaveCurrent}
|
||||
onSaveCurrent={state.onOpenSaveDialog}
|
||||
prPresets={state.resolvedPrPresets}
|
||||
issuePresets={state.resolvedIssuePresets}
|
||||
/>
|
||||
</aside>
|
||||
<main className="flex-1 flex flex-col min-w-0 overflow-hidden">
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { IconArrowLeft } from "@tabler/icons-react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@kandev/ui/card";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import { PageTopbar } from "@/components/page-topbar";
|
||||
import { ToggleGroup, ToggleGroupItem } from "@kandev/ui/toggle-group";
|
||||
import type { StatsResponse } from "@/lib/types/http";
|
||||
import { useMemo, useState } from "react";
|
||||
@@ -81,13 +80,7 @@ function getRangeLabel(range: RangeKey): string {
|
||||
function StatsEmptyState({ message }: { message: string }) {
|
||||
return (
|
||||
<div className="h-screen w-full flex flex-col bg-background">
|
||||
<header className="flex items-center gap-3 p-4 pb-3">
|
||||
<Link href="/" className="text-2xl font-bold hover:opacity-80 cursor-pointer">
|
||||
KanDev
|
||||
</Link>
|
||||
<span className="text-muted-foreground">/</span>
|
||||
<span className="text-muted-foreground">Statistics</span>
|
||||
</header>
|
||||
<PageTopbar title="Statistics" />
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<p className="text-muted-foreground">{message}</p>
|
||||
</div>
|
||||
@@ -105,56 +98,42 @@ type StatsHeaderProps = {
|
||||
|
||||
function StatsHeader({ global, range, copied, onRangeChange, onCopy }: StatsHeaderProps) {
|
||||
return (
|
||||
<header className="flex items-center gap-3 p-4 pb-3 shrink-0">
|
||||
<Button variant="ghost" size="sm" asChild className="cursor-pointer">
|
||||
<Link href="/">
|
||||
<IconArrowLeft className="h-4 w-4" />
|
||||
Back
|
||||
</Link>
|
||||
</Button>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Link href="/" className="font-semibold text-foreground hover:opacity-80 cursor-pointer">
|
||||
KanDev
|
||||
</Link>
|
||||
<span>›</span>
|
||||
<span>Statistics</span>
|
||||
<span className="text-muted-foreground/60">·</span>
|
||||
<span className="font-mono text-xs">
|
||||
{global.total_tasks} tasks · {global.total_sessions} sessions ·{" "}
|
||||
{formatDuration(global.total_duration_ms)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={range}
|
||||
onValueChange={(v) => {
|
||||
if (v) onRangeChange(v as RangeKey);
|
||||
}}
|
||||
variant="outline"
|
||||
className="h-7"
|
||||
>
|
||||
{(["week", "month", "all"] as RangeKey[]).map((key) => (
|
||||
<ToggleGroupItem
|
||||
key={key}
|
||||
value={key}
|
||||
className="cursor-pointer h-7 px-2 font-mono text-[11px] data-[state=on]:bg-muted data-[state=on]:text-foreground"
|
||||
>
|
||||
{getRangeLabel(key)}
|
||||
</ToggleGroupItem>
|
||||
))}
|
||||
</ToggleGroup>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 px-2 font-mono text-[11px] cursor-pointer"
|
||||
onClick={onCopy}
|
||||
>
|
||||
{copied ? "Copied" : "Copy Stats"}
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
<PageTopbar
|
||||
title="Statistics"
|
||||
subtitle={`${global.total_tasks} tasks · ${global.total_sessions} sessions · ${formatDuration(global.total_duration_ms)}`}
|
||||
actions={
|
||||
<>
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={range}
|
||||
onValueChange={(v) => {
|
||||
if (v) onRangeChange(v as RangeKey);
|
||||
}}
|
||||
variant="outline"
|
||||
className="h-7"
|
||||
>
|
||||
{(["week", "month", "all"] as RangeKey[]).map((key) => (
|
||||
<ToggleGroupItem
|
||||
key={key}
|
||||
value={key}
|
||||
className="cursor-pointer h-7 px-2 text-xs data-[state=on]:bg-muted data-[state=on]:text-foreground"
|
||||
>
|
||||
{getRangeLabel(key)}
|
||||
</ToggleGroupItem>
|
||||
))}
|
||||
</ToggleGroup>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs cursor-pointer"
|
||||
onClick={onCopy}
|
||||
>
|
||||
{copied ? "Copied" : "Copy Stats"}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -343,13 +322,7 @@ export function StatsPageClient({ stats, error, workspaceId, activeRange }: Stat
|
||||
if (error)
|
||||
return (
|
||||
<div className="h-screen w-full flex flex-col bg-background">
|
||||
<header className="flex items-center gap-3 p-4 pb-3">
|
||||
<Link href="/" className="text-2xl font-bold hover:opacity-80 cursor-pointer">
|
||||
KanDev
|
||||
</Link>
|
||||
<span className="text-muted-foreground">/</span>
|
||||
<span className="text-muted-foreground">Statistics</span>
|
||||
</header>
|
||||
<PageTopbar title="Statistics" />
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<p className="text-destructive">Error loading stats: {error}</p>
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,6 @@ 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";
|
||||
@@ -17,6 +16,26 @@ import {
|
||||
iconForPresetKey,
|
||||
} from "@/components/github/my-github/action-presets";
|
||||
import type { GitHubActionPreset } from "@/lib/types/github";
|
||||
import {
|
||||
ScriptEditor,
|
||||
computeEditorHeight,
|
||||
} from "@/components/settings/profile-edit/script-editor";
|
||||
import type { ScriptPlaceholder } from "@/components/settings/profile-edit/script-editor-completions";
|
||||
|
||||
const ACTION_PROMPT_PLACEHOLDERS: ScriptPlaceholder[] = [
|
||||
{
|
||||
key: "url",
|
||||
description: "URL of the PR or issue",
|
||||
example: "https://github.com/org/repo/pull/42",
|
||||
executor_types: [],
|
||||
},
|
||||
{
|
||||
key: "title",
|
||||
description: "Title of the PR or issue",
|
||||
example: "Fix login page crash",
|
||||
executor_types: [],
|
||||
},
|
||||
];
|
||||
|
||||
function newPreset(): GitHubActionPreset {
|
||||
return {
|
||||
@@ -31,7 +50,7 @@ function newPreset(): GitHubActionPreset {
|
||||
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">
|
||||
<SelectTrigger className="!h-8 py-0.5 text-sm cursor-pointer" aria-label="Icon">
|
||||
<SelectValue>
|
||||
{createElement(iconForPresetKey(value), { className: "h-4 w-4" })}
|
||||
</SelectValue>
|
||||
@@ -68,21 +87,35 @@ function PresetRow({
|
||||
}) {
|
||||
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}>
|
||||
<div className="flex items-end gap-2 p-2">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-[10px] text-muted-foreground">Icon</span>
|
||||
<PresetIconSelect value={preset.icon} onChange={(v) => onPatch({ icon: v })} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-[10px] text-muted-foreground">Label</span>
|
||||
<Input
|
||||
className="h-8 w-40"
|
||||
value={preset.label}
|
||||
placeholder="Label"
|
||||
onChange={(e) => onPatch({ label: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5 flex-1">
|
||||
<span className="text-[10px] text-muted-foreground">Hint</span>
|
||||
<Input
|
||||
className="h-8"
|
||||
value={preset.hint}
|
||||
placeholder="Hint (optional)"
|
||||
onChange={(e) => onPatch({ hint: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 cursor-pointer text-xs"
|
||||
onClick={onToggle}
|
||||
>
|
||||
{expanded ? "Hide prompt" : "Edit prompt"}
|
||||
</Button>
|
||||
<Button
|
||||
@@ -96,14 +129,23 @@ function PresetRow({
|
||||
</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 className="px-2 pb-2 space-y-1">
|
||||
<div className="rounded-md border overflow-hidden">
|
||||
<ScriptEditor
|
||||
value={preset.prompt_template}
|
||||
onChange={(v) => onPatch({ prompt_template: v })}
|
||||
language="markdown"
|
||||
height={computeEditorHeight(preset.prompt_template)}
|
||||
lineNumbers="off"
|
||||
placeholders={ACTION_PROMPT_PLACEHOLDERS}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground/60">
|
||||
Type {"{{"} to see available placeholders.{" "}
|
||||
<code className="bg-muted px-1 py-0.5 rounded text-[10px]">{"{{url}}"}</code> and{" "}
|
||||
<code className="bg-muted px-1 py-0.5 rounded text-[10px]">{"{{title}}"}</code> are
|
||||
substituted when the action runs.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useMemo } from "react";
|
||||
import { IconPlus, IconTrash, IconRefresh } from "@tabler/icons-react";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import { Input } from "@kandev/ui/input";
|
||||
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 {
|
||||
useDefaultQueryPresets,
|
||||
toStored,
|
||||
type StoredQueryPreset,
|
||||
} from "@/components/github/my-github/use-default-query-presets";
|
||||
import {
|
||||
PR_PRESETS as BUILTIN_PR_PRESETS,
|
||||
ISSUE_PRESETS as BUILTIN_ISSUE_PRESETS,
|
||||
} from "@/components/github/my-github/search-bar";
|
||||
|
||||
function newPreset(): StoredQueryPreset {
|
||||
return {
|
||||
value: `q_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`,
|
||||
label: "New query",
|
||||
filter: "",
|
||||
group: "inbox",
|
||||
};
|
||||
}
|
||||
|
||||
function QueryRow({
|
||||
preset,
|
||||
onPatch,
|
||||
onRemove,
|
||||
}: {
|
||||
preset: StoredQueryPreset;
|
||||
onPatch: (patch: Partial<StoredQueryPreset>) => void;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-md border p-2">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-[10px] text-muted-foreground">Label</span>
|
||||
<Input
|
||||
className="h-8 w-36"
|
||||
value={preset.label}
|
||||
placeholder="Label"
|
||||
onChange={(e) => onPatch({ label: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5 flex-1">
|
||||
<span className="text-[10px] text-muted-foreground">Query</span>
|
||||
<Input
|
||||
className="h-8 font-mono text-xs"
|
||||
value={preset.filter}
|
||||
placeholder="e.g. review-requested:@me is:open"
|
||||
onChange={(e) => onPatch({ filter: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-[10px] text-muted-foreground">Group</span>
|
||||
<Select
|
||||
value={preset.group}
|
||||
onValueChange={(v) => onPatch({ group: v as "inbox" | "created" })}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-28 cursor-pointer">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="inbox" className="cursor-pointer">
|
||||
Inbox
|
||||
</SelectItem>
|
||||
<SelectItem value="created" className="cursor-pointer">
|
||||
Created
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 cursor-pointer text-destructive mt-3.5"
|
||||
onClick={onRemove}
|
||||
aria-label="Remove"
|
||||
>
|
||||
<IconTrash className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function QueryEditor({
|
||||
presets,
|
||||
onChange,
|
||||
addLabel,
|
||||
}: {
|
||||
presets: StoredQueryPreset[];
|
||||
onChange: (presets: StoredQueryPreset[]) => void;
|
||||
addLabel: string;
|
||||
}) {
|
||||
const patch = useCallback(
|
||||
(index: number, change: Partial<StoredQueryPreset>) => {
|
||||
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(() => onChange([...presets, newPreset()]), [presets, onChange]);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{presets.map((preset, index) => (
|
||||
<QueryRow
|
||||
key={preset.value}
|
||||
preset={preset}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
export function DefaultQueriesSection() {
|
||||
const { toast } = useToast();
|
||||
const { prPresets, issuePresets, save, reset, isCustomized } = useDefaultQueryPresets();
|
||||
const [prDraft, setPrDraft] = useState<StoredQueryPreset[]>(prPresets);
|
||||
const [issueDraft, setIssueDraft] = useState<StoredQueryPreset[]>(issuePresets);
|
||||
|
||||
// Sync drafts when external state changes
|
||||
const [syncedPr, setSyncedPr] = useState(prPresets);
|
||||
const [syncedIssue, setSyncedIssue] = useState(issuePresets);
|
||||
if (prPresets !== syncedPr) {
|
||||
setSyncedPr(prPresets);
|
||||
setPrDraft(prPresets);
|
||||
}
|
||||
if (issuePresets !== syncedIssue) {
|
||||
setSyncedIssue(issuePresets);
|
||||
setIssueDraft(issuePresets);
|
||||
}
|
||||
|
||||
const dirty = useMemo(
|
||||
() =>
|
||||
JSON.stringify(prPresets) !== JSON.stringify(prDraft) ||
|
||||
JSON.stringify(issuePresets) !== JSON.stringify(issueDraft),
|
||||
[prPresets, issuePresets, prDraft, issueDraft],
|
||||
);
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
save({ pr: prDraft, issue: issueDraft });
|
||||
toast({ description: "Default queries saved", variant: "success" });
|
||||
}, [save, prDraft, issueDraft, toast]);
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
reset();
|
||||
setPrDraft(toStored(BUILTIN_PR_PRESETS));
|
||||
setIssueDraft(toStored(BUILTIN_ISSUE_PRESETS));
|
||||
toast({ description: "Default queries reset to defaults", variant: "success" });
|
||||
}, [reset, toast]);
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
title="Default queries"
|
||||
description="Sidebar queries shown on /github for pull requests and issues."
|
||||
action={
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleReset}
|
||||
disabled={!isCustomized}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<IconRefresh className="h-3.5 w-3.5 mr-1" />
|
||||
Reset
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleSave} disabled={!dirty} 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">
|
||||
<QueryEditor presets={prDraft} onChange={setPrDraft} addLabel="Add PR query" />
|
||||
</TabsContent>
|
||||
<TabsContent value="issue">
|
||||
<QueryEditor presets={issueDraft} onChange={setIssueDraft} addLabel="Add issue query" />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import { ReviewWatchDialog } from "./review-watch-dialog";
|
||||
import { IssueWatchTable } from "./issue-watch-table";
|
||||
import { IssueWatchDialog } from "./issue-watch-dialog";
|
||||
import { ActionPresetsSection } from "./action-presets-section";
|
||||
import { DefaultQueriesSection } from "./default-queries-section";
|
||||
import { PRStatsPanel } from "./pr-stats";
|
||||
import { useReviewWatches } from "@/hooks/domains/github/use-review-watches";
|
||||
import { useIssueWatches } from "@/hooks/domains/github/use-issue-watches";
|
||||
@@ -158,6 +159,7 @@ export function GitHubSettings({ workspaceId }: GitHubSettingsProps) {
|
||||
<ReviewWatchSection workspaceId={workspaceId} />
|
||||
<IssueWatchSection workspaceId={workspaceId} />
|
||||
<ActionPresetsSection workspaceId={workspaceId} />
|
||||
<DefaultQueriesSection />
|
||||
|
||||
<SettingsSection
|
||||
title="PR Analytics"
|
||||
|
||||
@@ -11,7 +11,15 @@ import {
|
||||
import { IconEye, IconSparkles } from "@tabler/icons-react";
|
||||
|
||||
describe("interpolatePromptTemplate", () => {
|
||||
it("replaces {url} and {title} placeholders", () => {
|
||||
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("supports legacy single-brace {url} and {title}", () => {
|
||||
const out = interpolatePromptTemplate("Review {url} titled {title}", {
|
||||
url: "https://gh/pr/1",
|
||||
title: "Fix bug",
|
||||
@@ -20,7 +28,7 @@ describe("interpolatePromptTemplate", () => {
|
||||
});
|
||||
|
||||
it("leaves unknown placeholders intact", () => {
|
||||
const out = interpolatePromptTemplate("See {url} and {unknown}", {
|
||||
const out = interpolatePromptTemplate("See {{url}} and {unknown}", {
|
||||
url: "u",
|
||||
title: "t",
|
||||
});
|
||||
@@ -67,7 +75,7 @@ describe("resolvePRPresets / resolveIssuePresets", () => {
|
||||
label: "Custom",
|
||||
hint: "h",
|
||||
icon: "eye",
|
||||
prompt_template: "Do it on {url}",
|
||||
prompt_template: "Do it on {{url}}",
|
||||
},
|
||||
],
|
||||
issue: [],
|
||||
@@ -86,7 +94,7 @@ describe("toTaskPreset", () => {
|
||||
label: "L",
|
||||
hint: "H",
|
||||
icon: "message",
|
||||
prompt_template: "On {url} for {title}",
|
||||
prompt_template: "On {{url}} for {{title}}",
|
||||
};
|
||||
const preset = toTaskPreset(stored);
|
||||
expect(preset.prompt({ url: "U", title: "T" })).toBe("On U for T");
|
||||
|
||||
@@ -36,16 +36,17 @@ export function iconForPresetKey(key: string | undefined): Icon {
|
||||
return ICON_BY_KEY[key] ?? IconSparkles;
|
||||
}
|
||||
|
||||
// Interpolate `{url}` and `{title}` placeholders in a prompt template.
|
||||
// Interpolate `{{url}}` and `{{title}}` placeholders in a prompt template.
|
||||
// Also supports legacy single-brace `{url}` / `{title}` for backward compat.
|
||||
// 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) => {
|
||||
return template.replace(/\{\{?(url|title)\}\}?/g, (_match, key) => {
|
||||
if (key === "url") return opts.url;
|
||||
if (key === "title") return opts.title;
|
||||
return `{${key}}`;
|
||||
return _match;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -66,7 +67,7 @@ export const DEFAULT_PR_PRESETS: GitHubActionPreset[] = [
|
||||
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.",
|
||||
"Review the pull request at {{url}}. Provide feedback on code quality, correctness, and suggest improvements.",
|
||||
},
|
||||
{
|
||||
id: "address_feedback",
|
||||
@@ -74,7 +75,7 @@ export const DEFAULT_PR_PRESETS: GitHubActionPreset[] = [
|
||||
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.",
|
||||
"Review the feedback on the pull request at {{url}}. Evaluate each comment critically — apply changes that improve the code, push back on suggestions that are unnecessary or harmful, and explain your reasoning. Push the changes when done.",
|
||||
},
|
||||
{
|
||||
id: "fix_ci",
|
||||
@@ -82,7 +83,7 @@ export const DEFAULT_PR_PRESETS: GitHubActionPreset[] = [
|
||||
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.",
|
||||
"Investigate and fix the CI failures and merge conflicts on the pull request at {{url}}. Run the failing checks locally, resolve any conflicts, diagnose issues, and push fixes.",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -93,7 +94,7 @@ export const DEFAULT_ISSUE_PRESETS: GitHubActionPreset[] = [
|
||||
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.',
|
||||
'Implement the changes described in the GitHub issue at {{url}} (title: "{{title}}"). Open a pull request when complete.',
|
||||
},
|
||||
{
|
||||
id: "investigate",
|
||||
@@ -101,7 +102,7 @@ export const DEFAULT_ISSUE_PRESETS: GitHubActionPreset[] = [
|
||||
hint: "Find the root cause",
|
||||
icon: "search",
|
||||
prompt_template:
|
||||
'Investigate the GitHub issue at {url} (title: "{title}"). Identify root cause and summarize findings.',
|
||||
'Investigate the GitHub issue at {{url}} (title: "{{title}}"). Identify root cause and summarize findings.',
|
||||
},
|
||||
{
|
||||
id: "reproduce",
|
||||
@@ -109,7 +110,7 @@ export const DEFAULT_ISSUE_PRESETS: GitHubActionPreset[] = [
|
||||
hint: "Document repro steps",
|
||||
icon: "bug",
|
||||
prompt_template:
|
||||
'Reproduce the bug described in the GitHub issue at {url} (title: "{title}"). Document the reproduction steps.',
|
||||
'Reproduce the bug described in the GitHub issue at {{url}} (title: "{{title}}"). Document the reproduction steps.',
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -19,6 +19,8 @@ type PresetsSidebarProps = {
|
||||
onDeleteSaved: (id: string) => void;
|
||||
canSaveCurrent: boolean;
|
||||
onSaveCurrent: () => void;
|
||||
prPresets?: PresetOption[];
|
||||
issuePresets?: PresetOption[];
|
||||
};
|
||||
|
||||
function KindToggle({
|
||||
@@ -200,11 +202,13 @@ export function PresetsSidebar({
|
||||
onDeleteSaved,
|
||||
canSaveCurrent,
|
||||
onSaveCurrent,
|
||||
prPresets = PR_PRESETS,
|
||||
issuePresets = ISSUE_PRESETS,
|
||||
}: PresetsSidebarProps) {
|
||||
const presets = selected.kind === "pr" ? PR_PRESETS : ISSUE_PRESETS;
|
||||
const presets = selected.kind === "pr" ? prPresets : issuePresets;
|
||||
const saved = savedPresets.filter((p) => p.kind === selected.kind);
|
||||
const onKindChange = (kind: "pr" | "issue") => {
|
||||
const fallback = (kind === "pr" ? PR_PRESETS : ISSUE_PRESETS)[0]?.value ?? "";
|
||||
const fallback = (kind === "pr" ? prPresets : issuePresets)[0]?.value ?? "";
|
||||
onSelect({ kind, source: "preset", id: fallback });
|
||||
};
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo, useSyncExternalStore } from "react";
|
||||
import {
|
||||
PR_PRESETS as BUILTIN_PR_PRESETS,
|
||||
ISSUE_PRESETS as BUILTIN_ISSUE_PRESETS,
|
||||
type PresetOption,
|
||||
} from "./search-bar";
|
||||
|
||||
const STORAGE_KEY = "kandev:github-default-queries:v1";
|
||||
|
||||
export type StoredQueryPreset = {
|
||||
value: string;
|
||||
label: string;
|
||||
filter: string;
|
||||
group: "inbox" | "created";
|
||||
};
|
||||
|
||||
type StoredDefaults = {
|
||||
pr: StoredQueryPreset[];
|
||||
issue: StoredQueryPreset[];
|
||||
};
|
||||
|
||||
export function toStored(presets: PresetOption[]): StoredQueryPreset[] {
|
||||
return presets.map(({ value, label, filter, group }) => ({ value, label, filter, group }));
|
||||
}
|
||||
|
||||
function readStorage(): StoredDefaults | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
try {
|
||||
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (
|
||||
typeof parsed !== "object" ||
|
||||
parsed === null ||
|
||||
!Array.isArray((parsed as StoredDefaults).pr) ||
|
||||
!Array.isArray((parsed as StoredDefaults).issue)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return parsed as StoredDefaults;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeStorage(defaults: StoredDefaults | null) {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
if (defaults === null) {
|
||||
window.localStorage.removeItem(STORAGE_KEY);
|
||||
} else {
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(defaults));
|
||||
}
|
||||
} catch {
|
||||
/* ignore quota / access errors */
|
||||
}
|
||||
}
|
||||
|
||||
let snapshot: StoredDefaults | null | undefined = undefined;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function publish(next: StoredDefaults | null) {
|
||||
snapshot = next;
|
||||
writeStorage(next);
|
||||
listeners.forEach((fn) => fn());
|
||||
}
|
||||
|
||||
function subscribe(cb: () => void) {
|
||||
listeners.add(cb);
|
||||
return () => {
|
||||
listeners.delete(cb);
|
||||
};
|
||||
}
|
||||
|
||||
function getSnapshot(): StoredDefaults | null {
|
||||
if (snapshot === undefined) snapshot = readStorage();
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
function getServerSnapshot(): StoredDefaults | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
export function useDefaultQueryPresets() {
|
||||
const stored = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
||||
|
||||
const prPresets = useMemo(() => stored?.pr ?? toStored(BUILTIN_PR_PRESETS), [stored]);
|
||||
const issuePresets = useMemo(() => stored?.issue ?? toStored(BUILTIN_ISSUE_PRESETS), [stored]);
|
||||
|
||||
const save = useCallback((defaults: StoredDefaults) => {
|
||||
publish(defaults);
|
||||
}, []);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
publish(null);
|
||||
}, []);
|
||||
|
||||
const isCustomized = stored !== null;
|
||||
|
||||
return { prPresets, issuePresets, save, reset, isCustomized };
|
||||
}
|
||||
|
||||
/** Resolve full PresetOption[] by merging stored presets with icon lookups from builtins. */
|
||||
export function resolvePresetOptions(
|
||||
stored: StoredQueryPreset[],
|
||||
builtins: PresetOption[],
|
||||
): PresetOption[] {
|
||||
const iconMap = new Map(builtins.map((b) => [b.value, b.icon]));
|
||||
const defaultIcon = builtins[0]?.icon;
|
||||
return stored.map((s) => ({
|
||||
...s,
|
||||
icon: iconMap.get(s.value) ?? defaultIcon,
|
||||
}));
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useEffect, useRef } from "react";
|
||||
import { IconRefresh } from "@tabler/icons-react";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
|
||||
import { useToast } from "@/components/toast-provider";
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
import { triggerAllReviewWatches, listReviewWatches } from "@/lib/api/domains/github-api";
|
||||
|
||||
export function RefreshReviewsButton() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { toast } = useToast();
|
||||
const workspaceId = useAppStore((state) => state.workspaces.activeId);
|
||||
const hasWatches = useAppStore((state) => state.reviewWatches.items.length > 0);
|
||||
const watchesLoaded = useAppStore((state) => state.reviewWatches.loaded);
|
||||
const setReviewWatches = useAppStore((state) => state.setReviewWatches);
|
||||
const fetchedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!workspaceId || watchesLoaded || fetchedRef.current) return;
|
||||
fetchedRef.current = true;
|
||||
listReviewWatches(workspaceId)
|
||||
.then((r) => setReviewWatches(r?.watches ?? []))
|
||||
.catch(() => {});
|
||||
}, [workspaceId, watchesLoaded, setReviewWatches]);
|
||||
|
||||
const handleTrigger = useCallback(async () => {
|
||||
if (!workspaceId || loading) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await triggerAllReviewWatches(workspaceId);
|
||||
const count = result?.new_prs_found ?? 0;
|
||||
if (count > 0) {
|
||||
toast({
|
||||
description: `Found ${count} new PR${count > 1 ? "s" : ""} to review`,
|
||||
variant: "success",
|
||||
});
|
||||
} else {
|
||||
toast({ description: "No new PRs to review" });
|
||||
}
|
||||
} catch {
|
||||
toast({ description: "Failed to check for review PRs", variant: "error" });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [workspaceId, loading, toast]);
|
||||
|
||||
if (!watchesLoaded || !hasWatches) return null;
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="cursor-pointer"
|
||||
onClick={handleTrigger}
|
||||
disabled={loading}
|
||||
>
|
||||
<IconRefresh className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Check for PRs to review</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -150,9 +150,13 @@ export function KanbanDisplayDropdown() {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" data-testid="display-button" className="cursor-pointer gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
data-testid="display-button"
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<IconAdjustmentsHorizontal className="h-4 w-4" />
|
||||
<span className="hidden 2xl:inline">Display</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-[280px] p-3">
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
IconBrandGithub,
|
||||
} from "@tabler/icons-react";
|
||||
import { KanbanDisplayDropdown } from "../kanban-display-dropdown";
|
||||
import { RefreshReviewsButton } from "../github/refresh-reviews-button";
|
||||
import { ReleaseNotesButton } from "../release-notes/release-notes-button";
|
||||
import { ReleaseNotesDialog } from "../release-notes/release-notes-dialog";
|
||||
import { HealthIndicatorButton, HealthIssuesDialog } from "../system-health/health-indicator";
|
||||
@@ -28,9 +27,9 @@ import { linkToTasks } from "@/lib/links";
|
||||
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
import { useKanbanDisplaySettings } from "@/hooks/use-kanban-display-settings";
|
||||
import { useGitHubStatus } from "@/hooks/domains/github/use-github-status";
|
||||
import { useReleaseNotes } from "@/hooks/use-release-notes";
|
||||
import { useSystemHealthIndicator } from "@/hooks/use-system-health-indicator";
|
||||
import { useGitHubStatus } from "@/hooks/domains/github/use-github-status";
|
||||
|
||||
type KanbanHeaderProps = {
|
||||
onCreateTask: () => void;
|
||||
@@ -57,16 +56,12 @@ function GitHubTopbarButton() {
|
||||
const { status } = useGitHubStatus();
|
||||
if (!status?.authenticated) return null;
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="outline" size="icon" asChild className="cursor-pointer">
|
||||
<Link href="/github">
|
||||
<IconBrandGithub className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>My GitHub — PRs & issues</TooltipContent>
|
||||
</Tooltip>
|
||||
<Button variant="outline" asChild className="cursor-pointer gap-2">
|
||||
<Link href="/github">
|
||||
<IconBrandGithub className="h-4 w-4" />
|
||||
<span>GitHub</span>
|
||||
</Link>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -144,9 +139,12 @@ function TabletHeader({
|
||||
}) {
|
||||
return (
|
||||
<header className="flex items-center justify-between p-4 pb-3 gap-3">
|
||||
<Link href="/" className="text-xl font-bold hover:opacity-80 flex-shrink-0">
|
||||
KanDev
|
||||
</Link>
|
||||
<div className="flex items-center gap-4 flex-shrink-0">
|
||||
<Link href="/" className="text-xl font-bold hover:opacity-80">
|
||||
KanDev
|
||||
</Link>
|
||||
<GitHubTopbarButton />
|
||||
</div>
|
||||
{onSearchChange && (
|
||||
<TaskSearchInput
|
||||
value={searchQuery}
|
||||
@@ -174,7 +172,6 @@ function TabletHeader({
|
||||
className="h-8"
|
||||
itemClassName="h-8 w-8"
|
||||
/>
|
||||
<GitHubTopbarButton />
|
||||
{showReleaseNotesButton && <ReleaseNotesButton hasUnseen onClick={onOpenReleaseNotes} />}
|
||||
<HealthIndicatorButton hasIssues={showHealthIndicator} onClick={onOpenHealthDialog} />
|
||||
</TooltipProvider>
|
||||
@@ -219,10 +216,19 @@ function DesktopHeader({
|
||||
}) {
|
||||
return (
|
||||
<header className="relative flex items-center justify-between p-4 pb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-5">
|
||||
<Link href="/" className="text-2xl font-bold hover:opacity-80">
|
||||
KanDev
|
||||
</Link>
|
||||
<div className="flex items-center gap-3">
|
||||
<GitHubTopbarButton />
|
||||
<Button variant="outline" asChild className="cursor-pointer gap-2">
|
||||
<Link href="/stats">
|
||||
<IconChartBar className="h-4 w-4" />
|
||||
<span>Stats</span>
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{onSearchChange && (
|
||||
<div className="absolute left-1/2 -translate-x-1/2">
|
||||
@@ -243,19 +249,7 @@ function DesktopHeader({
|
||||
<QuickChatButton workspaceId={workspaceId} />
|
||||
<TooltipProvider>
|
||||
<ViewToggleGroup toggleValue={toggleValue} onValueChange={handleViewChange} />
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="outline" size="icon" asChild className="cursor-pointer">
|
||||
<Link href="/stats">
|
||||
<IconChartBar className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Stats</TooltipContent>
|
||||
</Tooltip>
|
||||
<GitHubTopbarButton />
|
||||
</TooltipProvider>
|
||||
<RefreshReviewsButton />
|
||||
{showReleaseNotesButton && <ReleaseNotesButton hasUnseen onClick={onOpenReleaseNotes} />}
|
||||
<HealthIndicatorButton hasIssues={showHealthIndicator} onClick={onOpenHealthDialog} />
|
||||
<KanbanDisplayDropdown />
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import Link from "next/link";
|
||||
import { IconArrowLeft } from "@tabler/icons-react";
|
||||
|
||||
type PageTopbarProps = {
|
||||
/** Page title shown in the breadcrumb */
|
||||
title: string;
|
||||
/** Optional subtitle shown to the right of the title */
|
||||
subtitle?: string;
|
||||
/** Optional icon rendered before the title */
|
||||
icon?: React.ReactNode;
|
||||
/** Where the back link navigates to (default: "/") */
|
||||
backHref?: string;
|
||||
/** Optional content rendered on the right side of the topbar */
|
||||
actions?: React.ReactNode;
|
||||
};
|
||||
|
||||
export function PageTopbar({ title, subtitle, icon, backHref = "/", actions }: PageTopbarProps) {
|
||||
return (
|
||||
<header className="flex items-center gap-3 px-4 py-3 border-b shrink-0">
|
||||
<Link
|
||||
href={backHref}
|
||||
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground cursor-pointer transition-colors"
|
||||
>
|
||||
<IconArrowLeft className="h-3.5 w-3.5" />
|
||||
KanDev
|
||||
</Link>
|
||||
<span className="text-muted-foreground/50">›</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{icon}
|
||||
<span className="text-sm font-semibold">{title}</span>
|
||||
{subtitle && (
|
||||
<>
|
||||
<span className="text-muted-foreground/50">·</span>
|
||||
<span className="text-xs text-muted-foreground">{subtitle}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{actions && <div className="ml-auto flex items-center gap-2">{actions}</div>}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -160,6 +160,7 @@ export function ScriptEditor({
|
||||
padding: { top: 8, bottom: 8 },
|
||||
renderLineHighlight: "none",
|
||||
overviewRulerLanes: 0,
|
||||
fixedOverflowWidgets: true,
|
||||
wordBasedSuggestions: "off",
|
||||
scrollbar: {
|
||||
vertical: "auto",
|
||||
|
||||
@@ -270,14 +270,12 @@ export function SettingsAppSidebar() {
|
||||
|
||||
return (
|
||||
<Sidebar variant="inset">
|
||||
<SidebarHeader>
|
||||
<SidebarHeader className="h-16 justify-center">
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton size="lg" asChild>
|
||||
<Link href="/">
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-semibold">KanDev</span>
|
||||
</div>
|
||||
<span className="text-2xl font-bold">KanDev</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { IconGitBranch, IconCopy, IconCheck, IconPencil } from "@tabler/icons-re
|
||||
import { Input } from "@kandev/ui/input";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@kandev/ui/popover";
|
||||
import { ScrollOnOverflow } from "@kandev/ui/scroll-on-overflow";
|
||||
import { formatUserHomePath } from "@/lib/utils";
|
||||
import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard";
|
||||
|
||||
@@ -172,11 +173,14 @@ function BranchPillTrigger({
|
||||
canRename: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="group flex items-center gap-1.5 rounded-md px-2 h-7 bg-muted/40 hover:bg-muted/60 cursor-pointer transition-colors min-w-0 max-w-full">
|
||||
<div className="group flex items-center gap-1.5 rounded-md px-2 h-7 bg-muted/40 hover:bg-muted/60 cursor-pointer transition-colors min-w-0 max-w-64">
|
||||
<IconGitBranch className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
|
||||
<span data-testid="topbar-branch-name" className="text-xs text-muted-foreground truncate">
|
||||
<ScrollOnOverflow
|
||||
data-testid="topbar-branch-name"
|
||||
className="text-xs text-muted-foreground min-w-0"
|
||||
>
|
||||
{displayBranch}
|
||||
</span>
|
||||
</ScrollOnOverflow>
|
||||
{canRename && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { IconArrowRight, IconGitMerge, IconX } from "@tabler/icons-react";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
|
||||
import { TodoIndicator } from "./todo-indicator";
|
||||
import { getWebSocketClient } from "@/lib/ws/connection";
|
||||
import { useKeyboardShortcut } from "@/hooks/use-keyboard-shortcut";
|
||||
@@ -285,18 +286,23 @@ function ChatStatusBar({
|
||||
{showTodos && <TodoIndicator todos={todoItems} />}
|
||||
{taskId && <PRMergedBanner key={taskId} taskId={taskId} />}
|
||||
{showProceed && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="ml-auto h-6 gap-1 px-2 text-xs cursor-pointer hover:bg-muted/40"
|
||||
onClick={onProceed}
|
||||
disabled={isMoving}
|
||||
data-testid="proceed-next-step"
|
||||
>
|
||||
<span className="text-muted-foreground">{nextStepName}</span>
|
||||
<IconArrowRight className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="ml-auto h-6 gap-1 px-2.5 text-xs cursor-pointer text-primary"
|
||||
onClick={onProceed}
|
||||
disabled={isMoving}
|
||||
data-testid="proceed-next-step"
|
||||
>
|
||||
{nextStepName}
|
||||
<IconArrowRight className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Move task to the next workflow step</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { memo, useCallback, useRef, useState } from "react";
|
||||
import { IconChevronDown } from "@tabler/icons-react";
|
||||
import { IconCheck, IconChevronDown } from "@tabler/icons-react";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -94,14 +94,17 @@ export const ModeSelector = memo(function ModeSelector({ sessionId }: ModeSelect
|
||||
<DropdownMenuItem
|
||||
key={mode.id}
|
||||
onClick={() => handleModeChange(mode.id)}
|
||||
className={`cursor-pointer ${mode.id === modeState.currentModeId ? "bg-muted" : ""}`}
|
||||
className={`cursor-pointer relative pr-7 ${mode.id === modeState.currentModeId ? "bg-muted" : ""}`}
|
||||
>
|
||||
<div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div>{mode.name}</div>
|
||||
{mode.description && (
|
||||
<div className="text-xs text-muted-foreground">{mode.description}</div>
|
||||
)}
|
||||
</div>
|
||||
{mode.id === modeState.currentModeId && (
|
||||
<IconCheck className="absolute right-2 h-4 w-4" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
|
||||
@@ -192,6 +192,7 @@ export const ModelSelector = memo(function ModelSelector({ sessionId }: ModelSel
|
||||
searchPlaceholder="Filter models..."
|
||||
emptyMessage="No models found."
|
||||
showSearch={modelOptions.length > 5}
|
||||
dropdownLabel="Available Models"
|
||||
triggerClassName="h-7 gap-1 px-2 text-xs w-auto hover:bg-muted/40 whitespace-nowrap"
|
||||
className="min-w-[280px]"
|
||||
plainTrigger
|
||||
|
||||
@@ -17,13 +17,9 @@ export function QuickChatButton({ workspaceId }: { workspaceId?: string | null }
|
||||
|
||||
return (
|
||||
<KeyboardShortcutTooltip shortcut={quickChatShortcut} description="Quick Chat">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
className="cursor-pointer"
|
||||
onClick={handleOpenQuickChat}
|
||||
>
|
||||
<Button variant="outline" className="cursor-pointer gap-2" onClick={handleOpenQuickChat}>
|
||||
<IconMessageCircle className="h-4 w-4" />
|
||||
Chat
|
||||
</Button>
|
||||
</KeyboardShortcutTooltip>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user