fix: ui polish — unified topbar, selector consistency, quick actions editor improvements (#693)

This commit is contained in:
Carlos Florêncio
2026-04-24 21:31:46 +01:00
committed by GitHub
parent 1fc3544625
commit c092fdeb1c
22 changed files with 628 additions and 274 deletions
+7 -7
View File
@@ -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 -3
View File
@@ -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}
+52 -32
View File
@@ -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">
+39 -66
View File
@@ -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">
+23 -29
View File
@@ -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 />
+41
View File
@@ -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>
);
+6 -3
View File
@@ -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>
);