feat(web): polish workbench topbars and task-create dialog (#792)

This commit is contained in:
Carlos Florêncio
2026-05-03 16:15:28 +01:00
committed by GitHub
parent e9a7869463
commit baaf7d593a
21 changed files with 922 additions and 442 deletions
@@ -5,8 +5,10 @@ import {
getAvailableIntegrationLinks,
getGitHubIntegrationStatus,
IntegrationsMenu,
IntegrationsTopbarLinks,
} from "./integrations-menu";
import type { GitHubStatus } from "@/lib/types/github";
import { TooltipProvider } from "@kandev/ui/tooltip";
const useGitHubStatusMock = vi.hoisted(() => vi.fn());
const useJiraAvailableMock = vi.hoisted(() => vi.fn());
@@ -136,3 +138,29 @@ describe("IntegrationsMenu", () => {
expect(screen.queryByRole("button", { name: "Integrations" })).toBeNull();
});
});
function renderWithTooltip(component: Parameters<typeof render>[0]) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- TooltipProvider requires children in its type but createElement passes them as 3rd arg
return render(createElement(TooltipProvider, {} as any, component));
}
describe("IntegrationsTopbarLinks", () => {
it("renders an icon link for each configured integration", () => {
mockAvailability({ githubReady: true, jiraAvailable: false, linearAvailable: true });
renderWithTooltip(createElement(IntegrationsTopbarLinks, {}));
const githubLink = screen.getByRole("link", { name: "GitHub" });
const linearLink = screen.getByRole("link", { name: "Linear" });
expect(githubLink.getAttribute("href")).toBe("/github");
expect(linearLink.getAttribute("href")).toBe("/linear");
expect(screen.queryByRole("link", { name: "Jira" })).toBeNull();
});
it("renders nothing when no integrations are configured", () => {
mockAvailability({ githubReady: false, jiraAvailable: false, linearAvailable: false });
const { container } = renderWithTooltip(createElement(IntegrationsTopbarLinks, {}));
expect(container.firstChild).toBeNull();
});
});
@@ -10,6 +10,7 @@ import {
DropdownMenuLabel,
DropdownMenuTrigger,
} from "@kandev/ui/dropdown-menu";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { IconBrandGithub, IconHexagon, IconPlugConnected, IconTicket } from "@tabler/icons-react";
import { useJiraAvailable } from "@/hooks/domains/jira/use-jira-availability";
import { useLinearAvailable } from "@/hooks/domains/linear/use-linear-availability";
@@ -154,6 +155,31 @@ export function IntegrationsMenu() {
);
}
export function IntegrationsTopbarLinks() {
const links = useConfiguredIntegrationLinks();
if (links.length === 0) return null;
return (
<>
{links.map((link) => {
const Icon = INTEGRATION_ICONS[link.id];
return (
<Tooltip key={link.id}>
<TooltipTrigger asChild>
<Button asChild variant="outline" size="icon-lg" className="cursor-pointer">
<Link href={link.href} aria-label={link.label}>
<Icon className="h-4 w-4" />
</Link>
</Button>
</TooltipTrigger>
<TooltipContent>{link.label}</TooltipContent>
</Tooltip>
);
})}
</>
);
}
export function MobileIntegrationsSection({ onNavigate }: MobileIntegrationsSectionProps) {
const links = useConfiguredIntegrationLinks();
+41 -88
View File
@@ -4,14 +4,6 @@ import Link from "next/link";
import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@kandev/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@kandev/ui/dropdown-menu";
import { ToggleGroup, ToggleGroupItem } from "@kandev/ui/toggle-group";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@kandev/ui/tooltip";
import {
@@ -23,14 +15,12 @@ import {
IconChartBar,
IconTimeline,
IconStethoscope,
IconDots,
IconSparkles,
IconAlertTriangle,
} from "@tabler/icons-react";
import { ImproveKandevDialog } from "@/components/improve-kandev-dialog";
import { IntegrationsMenu } from "@/components/integrations/integrations-menu";
import { IntegrationsTopbarLinks } from "@/components/integrations/integrations-menu";
import { PageTopbar } from "@/components/page-topbar";
import { KanbanDisplayDropdown } from "../kanban-display-dropdown";
import { ReleaseNotesButton } from "../release-notes/release-notes-button";
import { ReleaseNotesDialog } from "../release-notes/release-notes-dialog";
import { HealthIndicatorButton, HealthIssuesDialog } from "../system-health/health-indicator";
import { TaskSearchInput } from "./task-search-input";
@@ -60,22 +50,13 @@ type ViewToggleItem = {
label: string;
};
type HeaderUtilityMenuProps = {
showReleaseNotesButton: boolean;
onOpenReleaseNotes: () => void;
showHealthIndicator: boolean;
onOpenHealthDialog: () => void;
showStatsLink?: boolean;
buttonSize?: ComponentProps<typeof Button>["size"];
};
const VIEW_TOGGLE_ITEMS: ViewToggleItem[] = [
{ value: "kanban", icon: IconLayoutKanban, label: "Kanban" },
{ value: "pipeline", icon: IconTimeline, label: "Pipeline" },
{ value: "list", icon: IconList, label: "List" },
];
const WORKBENCH_TOPBAR_CLASSNAME = "h-10 px-3 py-1";
const WORKBENCH_TOPBAR_CLASSNAME = "h-12 border-b-0 px-3 py-2";
const DESKTOP_HEADER_NARROW_PX = 1100;
function getWorkspaceLabel(
@@ -90,59 +71,18 @@ function getHeaderTitle(currentPage: string): string {
return currentPage === "tasks" ? "Tasks" : "Home";
}
function BoardUtilitiesMenu({
showReleaseNotesButton,
onOpenReleaseNotes,
showHealthIndicator,
onOpenHealthDialog,
showStatsLink = true,
buttonSize = "icon",
}: HeaderUtilityMenuProps) {
function SettingsTopbarButton({ size = "icon" }: { size?: ComponentProps<typeof Button>["size"] }) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size={buttonSize}
className="cursor-pointer"
aria-label="Utilities"
>
<IconDots className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-52">
<DropdownMenuLabel>Utilities</DropdownMenuLabel>
{showReleaseNotesButton && (
<DropdownMenuItem onClick={onOpenReleaseNotes} className="cursor-pointer">
<IconSparkles className="h-4 w-4" />
Release notes
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={onOpenHealthDialog} className="cursor-pointer">
<IconAlertTriangle
className={`h-4 w-4 ${showHealthIndicator ? "text-warning" : "text-muted-foreground"}`}
/>
{showHealthIndicator ? "Health issues" : "System health"}
</DropdownMenuItem>
{showStatsLink && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem asChild className="cursor-pointer">
<Link href="/stats">
<IconChartBar className="h-4 w-4" />
Stats
</Link>
</DropdownMenuItem>
</>
)}
<DropdownMenuItem asChild className="cursor-pointer">
<Link href="/settings">
<Tooltip>
<TooltipTrigger asChild>
<Button asChild variant="outline" size={size} className="cursor-pointer">
<Link href="/settings" aria-label="Settings">
<IconSettings className="h-4 w-4" />
Settings
</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</Button>
</TooltipTrigger>
<TooltipContent>Settings</TooltipContent>
</Tooltip>
);
}
@@ -181,17 +121,27 @@ function ImproveKandevTopbarButton({
);
}
function StatsTopbarButton() {
return (
<Tooltip>
<TooltipTrigger asChild>
<Button asChild variant="outline" size="icon-lg" className="cursor-pointer">
<Link href="/stats" aria-label="Stats">
<IconChartBar className="h-4 w-4" />
</Link>
</Button>
</TooltipTrigger>
<TooltipContent>Stats</TooltipContent>
</Tooltip>
);
}
function HomeLeftActions({ workspaceId }: { workspaceId?: string }) {
return (
<>
<Button asChild variant="outline" size="lg" className="cursor-pointer">
<Link href="/stats" aria-label="Stats">
<IconChartBar className="h-4 w-4" />
Stats
</Link>
</Button>
<IntegrationsTopbarLinks />
<StatsTopbarButton />
<ImproveKandevTopbarButton workspaceId={workspaceId} />
<IntegrationsMenu />
</>
);
}
@@ -199,8 +149,8 @@ function HomeLeftActions({ workspaceId }: { workspaceId?: string }) {
function WorkspaceLeftActions({ workspaceId }: { workspaceId?: string }) {
return (
<>
<IntegrationsTopbarLinks />
<ImproveKandevTopbarButton workspaceId={workspaceId} />
<IntegrationsMenu />
</>
);
}
@@ -368,6 +318,7 @@ function DesktopHeader({
toggleValue,
handleViewChange,
showReleaseNotesButton,
releaseNotesHasUnseen,
onOpenReleaseNotes,
showHealthIndicator,
onOpenHealthDialog,
@@ -382,6 +333,7 @@ function DesktopHeader({
toggleValue: string;
handleViewChange: (value: string) => void;
showReleaseNotesButton: boolean;
releaseNotesHasUnseen: boolean;
onOpenReleaseNotes: () => void;
showHealthIndicator: boolean;
onOpenHealthDialog: () => void;
@@ -439,14 +391,14 @@ function DesktopHeader({
onClick={onOpenHealthDialog}
size="icon-lg"
/>
<BoardUtilitiesMenu
showReleaseNotesButton={showReleaseNotesButton}
onOpenReleaseNotes={onOpenReleaseNotes}
showHealthIndicator={showHealthIndicator}
onOpenHealthDialog={onOpenHealthDialog}
showStatsLink={!isHome}
buttonSize="icon-lg"
/>
{showReleaseNotesButton && (
<ReleaseNotesButton
hasUnseen={releaseNotesHasUnseen}
onClick={onOpenReleaseNotes}
size="icon-lg"
/>
)}
<SettingsTopbarButton size="icon-lg" />
</>
}
/>
@@ -494,6 +446,7 @@ export function KanbanHeader({
const indicatorProps = {
showReleaseNotesButton: releaseNotes.showTopbarButton,
releaseNotesHasUnseen: releaseNotes.hasUnseen,
onOpenReleaseNotes: releaseNotes.openDialog,
showHealthIndicator: healthIndicator.hasIssues,
onOpenHealthDialog: healthIndicator.openDialog,
@@ -269,17 +269,17 @@ function MobileUtilityActions({
Release notes
</Button>
)}
<Button
type="button"
variant="outline"
className="w-full cursor-pointer justify-start gap-2"
onClick={openHealth}
>
<IconAlertTriangle
className={`h-4 w-4 ${showHealthIndicator ? "text-warning" : "text-muted-foreground"}`}
/>
{showHealthIndicator ? "Health issues" : "System health"}
</Button>
{showHealthIndicator && (
<Button
type="button"
variant="outline"
className="w-full cursor-pointer justify-start gap-2"
onClick={openHealth}
>
<IconAlertTriangle className="h-4 w-4 text-warning" />
Health issues
</Button>
)}
<Button asChild variant="outline" className="w-full cursor-pointer justify-start gap-2">
<Link href="/stats" onClick={closeSheet}>
<IconChartBar className="h-4 w-4 mr-2" />
+19 -9
View File
@@ -1,7 +1,7 @@
import Link from "next/link";
import { forwardRef } from "react";
import type { ReactNode } from "react";
import { IconArrowLeft } from "@tabler/icons-react";
import { IconArrowLeft, IconHome } from "@tabler/icons-react";
import {
Breadcrumb,
BreadcrumbItem,
@@ -58,7 +58,7 @@ export const PageTopbar = forwardRef<HTMLElement, PageTopbarProps>(function Page
return (
<header
ref={ref}
className={cn("relative flex h-14 shrink-0 items-center gap-3 border-b px-4", className)}
className={cn("relative flex h-10 shrink-0 items-center gap-3 border-b px-3 py-1", className)}
>
{leading}
{variant === "root" ? (
@@ -67,20 +67,30 @@ export const PageTopbar = forwardRef<HTMLElement, PageTopbarProps>(function Page
</div>
) : (
<Breadcrumb className="relative z-10 min-w-0">
<BreadcrumbList className="flex-nowrap">
<BreadcrumbList className="flex-nowrap text-sm">
<BreadcrumbItem className="shrink-0">
<BreadcrumbLink asChild className="flex items-center gap-1.5 cursor-pointer">
<Link href={backHref}>
<IconArrowLeft className="h-3.5 w-3.5" />
{backLabel}
</Link>
<BreadcrumbLink asChild>
{backHref === "/" ? (
<Link
href={backHref}
aria-label={backLabel}
className="cursor-pointer text-muted-foreground hover:text-foreground transition-colors"
>
<IconHome className="h-4 w-4" />
</Link>
) : (
<Link href={backHref} className="flex items-center gap-1.5 cursor-pointer">
<IconArrowLeft className="h-3.5 w-3.5" />
{backLabel}
</Link>
)}
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator className="shrink-0" />
<BreadcrumbItem className="min-w-0">
<BreadcrumbPage className="flex min-w-0 items-center gap-2">
{icon}
<span className="truncate text-sm font-semibold">{title}</span>
<span className="truncate text-sm font-medium">{title}</span>
{subtitle && (
<>
<span className="hidden text-muted-foreground/50 sm:inline">·</span>
@@ -117,7 +117,7 @@ export const QuickChatContent = memo(function QuickChatContent({
</div>
)}
{isQueued && queuedMessage && (
<div className="flex-shrink-0 border-t border-blue-400/30 bg-card px-3 py-1">
<div className="flex-shrink-0 bg-card px-3 pt-1.5">
<QueuedMessageIndicator
ref={queuedMessageRef}
content={queuedMessage.content}
@@ -1,5 +1,6 @@
"use client";
import type { ComponentProps } from "react";
import { Button } from "@kandev/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { IconSparkles } from "@tabler/icons-react";
@@ -7,13 +8,14 @@ import { IconSparkles } from "@tabler/icons-react";
type ReleaseNotesButtonProps = {
hasUnseen: boolean;
onClick: () => void;
size?: ComponentProps<typeof Button>["size"];
};
export function ReleaseNotesButton({ hasUnseen, onClick }: ReleaseNotesButtonProps) {
export function ReleaseNotesButton({ hasUnseen, onClick, size = "icon" }: ReleaseNotesButtonProps) {
return (
<Tooltip>
<TooltipTrigger asChild>
<Button variant="outline" size="icon" onClick={onClick} className="cursor-pointer relative">
<Button variant="outline" size={size} onClick={onClick} className="cursor-pointer relative">
<IconSparkles className="h-4 w-4" />
{hasUnseen && (
<span className="absolute -top-1 -right-1 h-2.5 w-2.5 rounded-full bg-primary border-2 border-background" />
@@ -0,0 +1,128 @@
"use client";
import { useMemo } from "react";
import { IconGitBranch } from "@tabler/icons-react";
import { cn } from "@/lib/utils";
import type { Branch } from "@/lib/types/http";
import { scoreBranch } from "@/lib/utils/branch-filter";
import {
Pill,
sortBranches,
branchToOption,
computeBranchPlaceholder,
} from "@/components/task-create-dialog-pill";
function computeUrlBranchDisabledReason({
hasUrl,
branchesLoading,
optionCount,
}: {
hasUrl: boolean;
branchesLoading: boolean;
optionCount: number;
}): string | undefined {
if (!hasUrl) return "Enter a GitHub URL first.";
if (branchesLoading) return "Loading branches…";
if (optionCount === 0) return "No branches available for this URL.";
return undefined;
}
export function GitHubUrlSection({
githubUrl,
githubUrlError,
githubBranch,
githubBranches,
githubBranchesLoading,
onGitHubUrlChange,
onGitHubBranchChange,
}: {
githubUrl: string;
githubUrlError: string | null;
githubBranch: string;
githubBranches: Branch[];
githubBranchesLoading: boolean;
onGitHubUrlChange?: (value: string) => void;
onGitHubBranchChange: (value: string) => void;
}) {
// URL mode is always remote-clone-based, so the branch is freely
// selectable regardless of executor type. Local executor's "branch is
// dictated by the user's checkout" rule does not apply here — there is
// no pre-existing local checkout for an arbitrary GitHub URL.
const branchOptions = useMemo(
() => sortBranches(githubBranches).map(branchToOption),
[githubBranches],
);
const branchPlaceholder = computeBranchPlaceholder(
!!githubUrl.trim(),
githubBranchesLoading,
branchOptions.length,
);
return (
<>
<GitHubUrlPill
githubUrl={githubUrl}
githubUrlError={githubUrlError}
onGitHubUrlChange={onGitHubUrlChange}
/>
<Pill
icon={<IconGitBranch className="h-3 w-3 shrink-0 text-muted-foreground" />}
value={githubBranch}
placeholder={branchPlaceholder}
options={branchOptions}
onSelect={onGitHubBranchChange}
disabled={!githubUrl.trim() || githubBranchesLoading || branchOptions.length === 0}
disabledReason={computeUrlBranchDisabledReason({
hasUrl: !!githubUrl.trim(),
branchesLoading: githubBranchesLoading,
optionCount: branchOptions.length,
})}
searchPlaceholder="Search branches..."
emptyMessage="No branches"
testId="branch-chip-trigger"
filter={scoreBranch}
tooltip="Base branch"
/>
</>
);
}
function GitHubUrlPill({
githubUrl,
githubUrlError,
onGitHubUrlChange,
}: {
githubUrl: string;
githubUrlError: string | null;
onGitHubUrlChange?: (value: string) => void;
}) {
return (
<div className="relative inline-flex items-center">
<input
type="text"
value={githubUrl}
onChange={(e) => onGitHubUrlChange?.(e.target.value)}
placeholder="github.com/owner/repo"
data-testid="github-url-input"
aria-label="GitHub repository URL"
aria-invalid={!!githubUrlError}
aria-describedby={githubUrlError ? "github-url-error" : undefined}
className={cn(
"h-7 rounded-md px-2.5 text-xs bg-muted/30 border border-border/60",
"outline-none focus:bg-muted focus:border-border placeholder:text-muted-foreground",
githubUrlError && "border-destructive text-destructive",
)}
autoFocus
/>
{githubUrlError && (
<div
id="github-url-error"
role="alert"
className="absolute left-0 top-full mt-1 z-50 rounded-md border bg-popover px-2 py-1 text-[11px] text-destructive shadow-md whitespace-nowrap"
data-testid="github-url-error"
>
{githubUrlError}
</div>
)}
</div>
);
}
@@ -0,0 +1,38 @@
import { describe, it, expect } from "vitest";
import type { Executor } from "@/lib/types/http";
import { computeExecutorHint } from "./task-create-dialog-options";
function exec(id: string, type: Executor["type"]): Executor {
return { id, type, name: id } as Executor;
}
const WORKTREE_SINGLE = "A git worktree will be created from the base branch.";
const WORKTREE_MULTI =
"A git worktree will be created for each repository in a parent folder. The agent runs in that parent folder so it can see every worktree side by side.";
const LOCAL = "The agent will run directly on the repository.";
describe("computeExecutorHint", () => {
const executors = [exec("wt", "worktree"), exec("loc", "local")];
it("returns the multi-repo worktree hint when more than one repo is selected", () => {
expect(computeExecutorHint(executors, "wt", 2)).toBe(WORKTREE_MULTI);
});
it("returns the single-repo worktree hint when exactly one repo is selected", () => {
expect(computeExecutorHint(executors, "wt", 1)).toBe(WORKTREE_SINGLE);
});
it("returns the local hint regardless of repoCount", () => {
expect(computeExecutorHint(executors, "loc", 1)).toBe(LOCAL);
expect(computeExecutorHint(executors, "loc", 5)).toBe(LOCAL);
});
it("returns null for an unknown executor id", () => {
expect(computeExecutorHint(executors, "nope", 1)).toBeNull();
});
it("returns null for an unrecognised executor type", () => {
const odd = [exec("x", "remote" as Executor["type"])];
expect(computeExecutorHint(odd, "x", 1)).toBeNull();
});
});
@@ -16,6 +16,7 @@ import { formatUserHomePath, truncateRepoPath } from "@/lib/utils";
import { getExecutorIcon } from "@/lib/executor-icons";
import { AgentLogo } from "@/components/agent-logo";
import { getCapabilityWarning } from "@/lib/capability-warning";
import { buildBranchKeywords } from "./task-create-dialog-pill";
type OptionItem = {
value: string;
@@ -114,20 +115,6 @@ export function useBranchOptions(branchOptionsRaw: Branch[]) {
}, [branchOptionsRaw]);
}
const BRANCH_SEGMENT_RE = /[/_.\-\s]+/;
function buildBranchKeywords(name: string, remote?: string): string[] {
const out = new Set<string>();
out.add(name);
const leafIdx = name.lastIndexOf("/");
if (leafIdx >= 0) out.add(name.slice(leafIdx + 1));
for (const seg of name.split(BRANCH_SEGMENT_RE)) {
if (seg) out.add(seg);
}
if (remote) out.add(remote);
return Array.from(out);
}
export function useAgentProfileOptions(agentProfiles: AgentProfileOption[]): OptionItem[] {
return useMemo(() => {
return agentProfiles.map((profile: AgentProfileOption) => {
@@ -193,14 +180,31 @@ export function useIsLocalExecutor(executors: Executor[], executorId: string): b
}, [executors, executorId]);
}
export function useExecutorHint(executors: Executor[], executorId: string): string | null {
return useMemo(() => {
const selectedExecutor = executors.find((e: Executor) => e.id === executorId);
if (selectedExecutor?.type === "worktree")
return "A git worktree will be created from the base branch.";
if (selectedExecutor?.type === "local") return "The agent will run directly on the repository.";
return null;
}, [executors, executorId]);
export function computeExecutorHint(
executors: Executor[],
executorId: string,
repoCount: number,
): string | null {
const selectedExecutor = executors.find((e: Executor) => e.id === executorId);
if (selectedExecutor?.type === "worktree") {
if (repoCount > 1) {
return "A git worktree will be created for each repository in a parent folder. The agent runs in that parent folder so it can see every worktree side by side.";
}
return "A git worktree will be created from the base branch.";
}
if (selectedExecutor?.type === "local") return "The agent will run directly on the repository.";
return null;
}
export function useExecutorHint(
executors: Executor[],
executorId: string,
repoCount: number,
): string | null {
return useMemo(
() => computeExecutorHint(executors, executorId, repoCount),
[executors, executorId, repoCount],
);
}
export type ExecutorProfileOptionItem = OptionItem & {
@@ -0,0 +1,86 @@
import { describe, it, expect } from "vitest";
import type { Branch } from "@/lib/types/http";
import { sortBranches, branchToOption, computeBranchPlaceholder } from "./task-create-dialog-pill";
function localBranch(name: string): Branch {
return { name, type: "local" } as Branch;
}
function remoteBranch(name: string, remote = "origin"): Branch {
return { name, type: "remote", remote } as Branch;
}
describe("sortBranches", () => {
it("lifts main before master before develop before other branches", () => {
const sorted = sortBranches([
localBranch("feature/a"),
localBranch("develop"),
localBranch("master"),
localBranch("main"),
]);
expect(sorted.map((b) => b.name)).toEqual(["main", "master", "develop", "feature/a"]);
});
it("puts local before remote when both have the same preferred name", () => {
const sorted = sortBranches([remoteBranch("main"), localBranch("main")]);
expect(sorted.map((b) => b.type)).toEqual(["local", "remote"]);
});
it("leaves non-preferred branches in their original relative order", () => {
const sorted = sortBranches([
localBranch("feature/zeta"),
localBranch("feature/alpha"),
localBranch("feature/middle"),
]);
expect(sorted.map((b) => b.name)).toEqual(["feature/zeta", "feature/alpha", "feature/middle"]);
});
it("does not mutate the input array", () => {
const input = [localBranch("feature/a"), localBranch("main")];
const snapshot = input.map((b) => b.name);
sortBranches(input);
expect(input.map((b) => b.name)).toEqual(snapshot);
});
});
describe("branchToOption keywords", () => {
function keywords(b: Branch): string[] {
return branchToOption(b).keywords ?? [];
}
it("includes the leaf segment for slash-prefixed branches", () => {
expect(keywords(localBranch("feat/scope/thing"))).toContain("thing");
});
it("splits on slashes, dots, underscores, and hyphens", () => {
const kw = keywords(localBranch("feat/scope.thing_with-dash"));
expect(kw).toEqual(expect.arrayContaining(["feat", "scope", "thing", "with", "dash"]));
});
it("includes the remote name when present", () => {
expect(keywords(remoteBranch("main", "upstream"))).toContain("upstream");
});
it("dedupes repeated segments", () => {
const kw = keywords(localBranch("foo/foo"));
expect(kw.filter((k) => k === "foo")).toHaveLength(1);
});
});
describe("computeBranchPlaceholder", () => {
it("returns 'branch' when no repo is selected", () => {
expect(computeBranchPlaceholder(false, false, 0)).toBe("branch");
});
it("returns 'loading…' while branches are loading", () => {
expect(computeBranchPlaceholder(true, true, 0)).toBe("loading…");
});
it("returns 'no branches' when loaded but the list is empty", () => {
expect(computeBranchPlaceholder(true, false, 0)).toBe("no branches");
});
it("returns 'branch' as the default with options available", () => {
expect(computeBranchPlaceholder(true, false, 3)).toBe("branch");
});
});
@@ -0,0 +1,254 @@
"use client";
import { useState } from "react";
import { cn } from "@/lib/utils";
import { Popover, PopoverContent, PopoverTrigger } from "@kandev/ui/popover";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { Badge } from "@kandev/ui/badge";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@kandev/ui/command";
import type { Branch } from "@/lib/types/http";
import { BranchRefreshButton } from "@/components/branch-refresh-button";
export type PillOption = {
value: string;
label: string;
keywords?: string[];
renderLabel?: () => React.ReactNode;
};
type PillProps = {
icon: React.ReactNode;
value: string;
placeholder: string;
options: PillOption[];
onSelect: (value: string) => void;
disabled?: boolean;
/** When provided alongside `disabled`, surfaces a tooltip explaining why. */
disabledReason?: string;
searchPlaceholder: string;
emptyMessage: string;
testId?: string;
/** Optional refresh action rendered next to the search input. */
onRefresh?: () => void;
/** Show the refresh icon as spinning + disabled while a refresh is in flight. */
refreshing?: boolean;
/**
* Render without its own border/bg so the pill blends into a wrapping
* grouped container (used by RepoChip to draw one rectangle around
* repo + branch + remove).
*/
flat?: boolean;
/** Optional cmdk scorer override. Branch pickers pass `scoreBranch`. */
filter?: (value: string, search: string, keywords?: string[]) => number;
/** Optional hover tooltip for truncated labels or extra context. */
tooltip?: string;
};
/** Returns the active-state hover classes for the pill trigger button. */
function pillActiveClass(flat: boolean): string {
if (flat) return "hover:bg-muted/60 cursor-pointer";
return "hover:bg-muted hover:border-border cursor-pointer";
}
function PillCommandList({
options,
onSelect,
setOpen,
emptyMessage,
}: {
options: PillOption[];
onSelect: (value: string) => void;
setOpen: (open: boolean) => void;
emptyMessage: string;
}) {
return (
<CommandList>
<CommandEmpty>{emptyMessage}</CommandEmpty>
<CommandGroup>
{options.map((option) => (
<CommandItem
key={option.value}
value={option.value}
keywords={[option.label, ...(option.keywords ?? [])]}
onSelect={() => {
onSelect(option.value);
setOpen(false);
}}
>
{option.renderLabel ? option.renderLabel() : option.label}
</CommandItem>
))}
</CommandGroup>
</CommandList>
);
}
/**
* Compact pill trigger that opens a popover with a search list. Auto-widths
* to its content (no `w-full`, no chevron) so multiple pills can sit on one
* line without overlapping or stretching to fill the row.
*/
export function Pill({
icon,
value,
placeholder,
options,
onSelect,
disabled = false,
disabledReason,
searchPlaceholder,
emptyMessage,
testId,
onRefresh,
refreshing,
flat = false,
filter,
tooltip,
}: PillProps) {
const [open, setOpen] = useState(false);
const hasValue = !!value;
const triggerButton = (
<button
type="button"
disabled={disabled}
data-testid={testId}
className={cn(
"h-7 inline-flex items-center gap-1.5 rounded-md px-2.5 text-xs",
flat ? "bg-transparent" : "border border-border/60 bg-muted/30",
disabled ? "opacity-50 cursor-not-allowed" : pillActiveClass(flat),
!hasValue && "text-muted-foreground",
)}
>
{icon}
<span className="truncate max-w-[160px]">{value || placeholder}</span>
</button>
);
// A disabled <button> swallows pointer events, so wrap it in a span that
// forwards hover to the tooltip trigger. Keep the Popover render path while
// the popover is open even if `disabled` flips true mid-interaction (e.g.
// refresh sets branchesLoading=true) — otherwise the popover unmounts and
// the user loses their place.
if (disabled && disabledReason && !open) {
return (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex">{triggerButton}</span>
</TooltipTrigger>
<TooltipContent>{disabledReason}</TooltipContent>
</Tooltip>
);
}
const popover = (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
{tooltip ? <TooltipTrigger asChild>{triggerButton}</TooltipTrigger> : triggerButton}
</PopoverTrigger>
<PopoverContent className="w-72 p-0" align="start" portal={false}>
<Command filter={filter}>
<div className="flex items-center gap-1 px-2 pt-1">
<CommandInput placeholder={searchPlaceholder} className="h-9 flex-1" />
{onRefresh && <BranchRefreshButton onRefresh={onRefresh} refreshing={refreshing} />}
</div>
<PillCommandList
options={options}
onSelect={onSelect}
setOpen={setOpen}
emptyMessage={emptyMessage}
/>
</Command>
</PopoverContent>
</Popover>
);
if (!tooltip) return popover;
// Suppress the hover tooltip while the popover is open so they don't stack.
return (
<Tooltip open={open ? false : undefined}>
{popover}
<TooltipContent>{tooltip}</TooltipContent>
</Tooltip>
);
}
// --- Branch utilities ---
// Conventional default branches surfaced at the top of the dropdown when no
// search term is active. cmdk preserves option order on empty queries, so a
// stable sort here lifts main/master/develop above feature branches.
const PREFERRED_BRANCH_NAMES = ["main", "master", "develop"];
function branchPriority(b: Branch): number {
const idx = PREFERRED_BRANCH_NAMES.indexOf(b.name);
if (idx === -1) return PREFERRED_BRANCH_NAMES.length;
return idx;
}
export function sortBranches(branches: Branch[]): Branch[] {
return [...branches].sort((a, b) => {
const pa = branchPriority(a);
const pb = branchPriority(b);
if (pa !== pb) return pa - pb;
// Within the same priority bucket, locals before remotes — matches the
// auto-select preference (`main` over `origin/main`).
if (a.type !== b.type) return a.type === "local" ? -1 : 1;
return 0;
});
}
const BRANCH_SEGMENT_RE = /[/_.\-\s]+/;
export function buildBranchKeywords(name: string, remote?: string): string[] {
const out = new Set<string>();
out.add(name);
const leafIdx = name.lastIndexOf("/");
if (leafIdx >= 0) out.add(name.slice(leafIdx + 1));
for (const seg of name.split(BRANCH_SEGMENT_RE)) {
if (seg) out.add(seg);
}
if (remote) out.add(remote);
return Array.from(out);
}
export function branchToOption(b: Branch): PillOption {
// Remote branches keep their "origin/" prefix so they're distinguishable
// from local branches with the same short name (e.g. "main" vs "origin/main").
// Without the prefix, the dropdown shows two indistinguishable rows.
const display = b.type === "remote" && b.remote ? `${b.remote}/${b.name}` : b.name;
const badge = b.type === "local" ? "local" : (b.remote ?? "remote");
return {
value: display,
label: display,
keywords: buildBranchKeywords(b.name, b.remote),
renderLabel: () => (
<span className="flex min-w-0 flex-1 items-center justify-between gap-2">
<span className="truncate" title={display}>
{display}
</span>
<Badge variant="outline" className="text-xs shrink-0">
{badge}
</Badge>
</span>
),
};
}
export function computeBranchPlaceholder(
hasRepo: boolean,
loading: boolean,
optionCount: number,
): string {
if (!hasRepo) return "branch";
if (loading) return "loading…";
if (optionCount === 0) return "no branches";
return "branch";
}
@@ -2,6 +2,7 @@ import { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import type { Branch, Repository } from "@/lib/types/http";
import type { DialogFormState, TaskRepoRow } from "./task-create-dialog-types";
import { TooltipProvider } from "@kandev/ui/tooltip";
// One mocked branches hook now — id-based and path-based rows go through
// the same loader. Tests can override the return value when they need
@@ -66,10 +67,14 @@ function makeFs(overrides: Partial<DialogFormState>): DialogFormState {
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- noop for test-only callback signature
const NOOP = (_key: string, _value: string) => undefined;
function renderInProvider(ui: Parameters<typeof render>[0]) {
return render(<TooltipProvider>{ui}</TooltipProvider>);
}
// eslint-disable-next-line max-lines-per-function -- test describe block, splitting hurts readability
describe("RepoChipsRow", () => {
it("renders one chip per row plus an Add button", () => {
render(
renderInProvider(
<RepoChipsRow
fs={makeFs({ repositories: [row({ key: "r0", repositoryId: REPO_FRONT_ID })] })}
repositories={[makeRepo(REPO_FRONT_ID, "frontend"), makeRepo(REPO_BACK_ID, "backend")]}
@@ -84,7 +89,7 @@ describe("RepoChipsRow", () => {
});
it("renders one chip per row across multiple repos", () => {
render(
renderInProvider(
<RepoChipsRow
fs={makeFs({
repositories: [
@@ -103,7 +108,7 @@ describe("RepoChipsRow", () => {
});
it("renders the GitHub URL input in URL mode (chips suppressed)", () => {
render(
renderInProvider(
<RepoChipsRow
fs={makeFs({ useGitHubUrl: true, githubUrl: "" })}
repositories={[makeRepo(REPO_FRONT_ID, "frontend")]}
@@ -121,7 +126,7 @@ describe("RepoChipsRow", () => {
});
it("hides the chip row when the task is already started", () => {
render(
renderInProvider(
<RepoChipsRow
fs={makeFs({ repositories: [row({ key: "r0", repositoryId: REPO_FRONT_ID })] })}
repositories={[makeRepo(REPO_FRONT_ID, "frontend")]}
@@ -135,7 +140,7 @@ describe("RepoChipsRow", () => {
});
it("disables Add when no more repositories are available", () => {
render(
renderInProvider(
<RepoChipsRow
fs={makeFs({
repositories: [
@@ -156,7 +161,7 @@ describe("RepoChipsRow", () => {
it("calls fs.addRepository when the + button is clicked", () => {
const fs = makeFs({ repositories: [row({ key: "r0", repositoryId: REPO_FRONT_ID })] });
render(
renderInProvider(
<RepoChipsRow
fs={fs}
repositories={[makeRepo(REPO_FRONT_ID, "frontend"), makeRepo(REPO_BACK_ID, "backend")]}
@@ -177,7 +182,7 @@ describe("RepoChipsRow", () => {
row({ key: "r1", repositoryId: REPO_BACK_ID, branch: "develop" }),
],
});
render(
renderInProvider(
<RepoChipsRow
fs={fs}
repositories={[makeRepo(REPO_FRONT_ID, "frontend"), makeRepo(REPO_BACK_ID, "backend")]}
@@ -195,7 +200,7 @@ describe("RepoChipsRow", () => {
// dropdown alongside workspace repos. A previous rewrite passed [] for
// discovered repos and lost them.
it("includes discovered (on-machine) repos in the picker dropdown", () => {
render(
renderInProvider(
<RepoChipsRow
fs={makeFs({
repositories: [row({ key: "r0" })],
@@ -224,7 +229,7 @@ describe("RepoChipsRow", () => {
// a workspace `repository_id`, causing FK failures on submit.
it("calls onRowRepositoryChange with the discovered path when picked", () => {
const onRowRepositoryChange = vi.fn();
render(
renderInProvider(
<RepoChipsRow
fs={makeFs({
repositories: [row({ key: "r0" })],
@@ -249,7 +254,7 @@ describe("RepoChipsRow", () => {
// build a `kind: "path"` source for discovered rows so the unified hook
// hits the path-based query param instead of trying an id lookup.
it("discovered rows build a path-source for the branch loader", () => {
render(
renderInProvider(
<RepoChipsRow
fs={makeFs({
repositories: [row({ key: "r0", localPath: "/home/me/projects/proj" })],
@@ -269,7 +274,7 @@ describe("RepoChipsRow", () => {
});
it("workspace rows build an id-source for the branch loader", () => {
render(
renderInProvider(
<RepoChipsRow
fs={makeFs({ repositories: [row({ key: "r0", repositoryId: REPO_FRONT_ID })] })}
repositories={[makeRepo(REPO_FRONT_ID, "frontend")]}
@@ -297,7 +302,7 @@ describe("RepoChipsRow", () => {
{ name: "main", type: "remote", remote: "origin" },
] as unknown as Branch[],
};
render(
renderInProvider(
<RepoChipsRow
fs={makeFs({ repositories: [row({ key: "r0", repositoryId: REPO_FRONT_ID })] })}
repositories={[makeRepo(REPO_FRONT_ID, "frontend")]}
@@ -1,22 +1,22 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo } from "react";
import { IconPlus, IconX, IconCode, IconGitBranch, IconGitFork } from "@tabler/icons-react";
import { cn } from "@/lib/utils";
import { Popover, PopoverContent, PopoverTrigger } from "@kandev/ui/popover";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@kandev/ui/command";
import { cn, formatUserHomePath } from "@/lib/utils";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { useBranches, type BranchSource } from "@/hooks/domains/workspace/use-repository-branches";
import type { Branch, LocalRepository, Repository } from "@/lib/types/http";
import type { LocalRepository, Repository } from "@/lib/types/http";
import type { DialogFormState, TaskRepoRow } from "@/components/task-create-dialog-types";
import { autoSelectBranch } from "@/components/task-create-dialog-helpers";
import { BranchRefreshButton } from "@/components/branch-refresh-button";
import { scoreBranch } from "@/lib/utils/branch-filter";
import {
Pill,
sortBranches,
branchToOption,
computeBranchPlaceholder,
type PillOption,
} from "@/components/task-create-dialog-pill";
import { GitHubUrlSection } from "@/components/task-create-dialog-github-url";
/**
* Chip row for the task-create dialog. Renders one chip per row in
@@ -87,7 +87,7 @@ export function RepoChipsRow({
// pick from discovered on-machine paths.
const hasDiscovered = fs.discoveredRepositories.length > 0;
const canAddMore = remainingCount > 0 || hasDiscovered;
const addHint = canAddMore ? undefined : "All workspace repositories are already added";
const addHint = computeAddHint(canAddMore, repositories.length);
return (
<div className="flex flex-wrap items-center gap-2" data-testid="repo-chips-row">
@@ -111,29 +111,14 @@ export function RepoChipsRow({
addHint={addHint}
onRowRepositoryChange={onRowRepositoryChange}
onRowBranchChange={onRowBranchChange}
/>
)}
{freshBranchAvailable && onToggleFreshBranch && (
<button
type="button"
onClick={() => onToggleFreshBranch(!freshBranchEnabled)}
data-testid="fresh-branch-toggle"
aria-pressed={!!freshBranchEnabled}
aria-label={freshBranchEnabled ? "Fork a new branch" : "Use current branch"}
className={cn(
"inline-flex h-7 w-7 items-center justify-center rounded-md border border-input cursor-pointer transition-colors",
freshBranchEnabled
? "bg-muted text-foreground"
: "bg-transparent text-muted-foreground hover:text-foreground hover:bg-muted/60",
)}
title={
freshBranchEnabled
? "Will fork a new branch from the selected base"
: "Use the current branch (no fork)"
freshBranchToggle={
// Multi-repo runs use worktrees, so the existing-vs-fork choice
// is irrelevant — only surface the toggle for single-repo flows.
freshBranchAvailable && onToggleFreshBranch && fs.repositories.length === 1 ? (
<FreshBranchToggle enabled={!!freshBranchEnabled} onToggle={onToggleFreshBranch} />
) : null
}
>
<IconGitFork className="h-3.5 w-3.5" />
</button>
/>
)}
{onToggleGitHubUrl && (
<button
@@ -161,6 +146,7 @@ function ChipsList({
branchLocked,
canAddMore,
addHint,
freshBranchToggle,
onRowRepositoryChange,
onRowBranchChange,
}: {
@@ -170,6 +156,7 @@ function ChipsList({
branchLocked: boolean;
canAddMore: boolean;
addHint?: string;
freshBranchToggle?: React.ReactNode;
onRowRepositoryChange: (key: string, value: string) => void;
onRowBranchChange: (key: string, value: string) => void;
}) {
@@ -190,26 +177,74 @@ function ChipsList({
onRemove={() => fs.removeRepository(row.key)}
/>
))}
<button
type="button"
onClick={fs.addRepository}
disabled={!canAddMore}
title={addHint}
aria-label="Add repository"
data-testid="add-repository"
className={cn(
"h-7 w-7 inline-flex items-center justify-center rounded-md text-muted-foreground",
canAddMore
? "hover:bg-muted hover:text-foreground cursor-pointer"
: "opacity-40 cursor-not-allowed",
)}
>
<IconPlus className="h-3.5 w-3.5" />
</button>
{freshBranchToggle}
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex">
<button
type="button"
onClick={fs.addRepository}
disabled={!canAddMore}
aria-label="Add repository"
data-testid="add-repository"
className={cn(
"h-7 w-7 inline-flex items-center justify-center rounded-md text-muted-foreground",
canAddMore
? "hover:bg-muted hover:text-foreground cursor-pointer"
: "opacity-40 cursor-not-allowed",
)}
>
<IconPlus className="h-3.5 w-3.5" />
</button>
</span>
</TooltipTrigger>
<TooltipContent>{addHint ?? "Add another repository"}</TooltipContent>
</Tooltip>
</>
);
}
function FreshBranchToggle({
enabled,
onToggle,
}: {
enabled: boolean;
onToggle: (enabled: boolean) => void;
}) {
return (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => onToggle(!enabled)}
data-testid="fresh-branch-toggle"
aria-pressed={enabled}
aria-label={enabled ? "Fork a new branch" : "Use current branch"}
className={cn(
"inline-flex h-7 w-7 items-center justify-center rounded-md border border-input cursor-pointer transition-colors",
enabled
? "bg-muted text-foreground"
: "bg-transparent text-muted-foreground hover:text-foreground hover:bg-muted/60",
)}
>
<IconGitFork className="h-3.5 w-3.5" />
</button>
</TooltipTrigger>
<TooltipContent>
{enabled
? "Forking a new branch from the selected base. Click to use the existing branch instead."
: "Use the existing branch. Click to fork a new branch from a base instead."}
</TooltipContent>
</Tooltip>
);
}
function computeAddHint(canAddMore: boolean, workspaceRepoCount: number): string | undefined {
if (canAddMore) return undefined;
if (workspaceRepoCount === 0) return "No repositories available in this workspace";
return "All workspace repositories are already added";
}
/** Build the set of repo identifiers (workspace id or path) currently in use. */
function collectUsedRepoIds(rows: TaskRepoRow[], exceptKey?: string): Set<string> {
const ids = new Set<string>();
@@ -323,10 +358,26 @@ function useRepoChipData({
],
[filteredRepos, filteredDiscovered],
);
const branchOptions: PillOption[] = useMemo(() => branches.map(branchToOption), [branches]);
const branchOptions: PillOption[] = useMemo(
() => sortBranches(branches).map(branchToOption),
[branches],
);
return { repoOptions, branchOptions, branchesLoading, refreshBranches };
}
function computeRepoChipDisplay(
row: TaskRepoRow,
repositories: Repository[],
discoveredRepositories: LocalRepository[],
) {
const workspaceRepo = repositories.find((r) => r.id === row.repositoryId);
const discoveredRepo = discoveredRepositories.find((r) => r.path === row.localPath);
const repoLabel = workspaceRepo?.name ?? discoveredRepo?.path?.split("/").pop() ?? "";
const repoPath = workspaceRepo?.local_path || discoveredRepo?.path || "";
const repoTooltip = repoPath ? `Repository · ${formatUserHomePath(repoPath)}` : "Repository";
return { repoLabel, repoTooltip };
}
function RepoChip({
row,
workspaceId,
@@ -347,13 +398,12 @@ function RepoChip({
excludedRepoIds,
onBranchChange,
});
const repoLabel =
repositories.find((r) => r.id === row.repositoryId)?.name ??
discoveredRepositories
.find((r) => r.path === row.localPath)
?.path?.split("/")
.pop() ??
"";
const { repoLabel, repoTooltip } = computeRepoChipDisplay(
row,
repositories,
discoveredRepositories,
);
const branchValue = branchOverride ?? row.branch;
const hasRepo = !!(row.repositoryId || row.localPath);
const branchPlaceholder = computeBranchPlaceholder(
hasRepo,
@@ -363,7 +413,7 @@ function RepoChip({
return (
<span
className="inline-flex items-center gap-1"
className="inline-flex items-center rounded-md border border-input bg-input/20 dark:bg-input/30 pr-0.5"
data-testid="repo-chip"
data-repository-id={row.repositoryId || row.localPath || ""}
>
@@ -376,40 +426,67 @@ function RepoChip({
searchPlaceholder="Search repositories..."
emptyMessage="No repositories"
testId="repo-chip-trigger"
tooltip={repoTooltip}
flat
/>
<Pill
icon={<IconGitBranch className="h-3 w-3 shrink-0 text-muted-foreground" />}
value={branchOverride ?? row.branch}
value={branchValue}
placeholder={branchPlaceholder}
options={branchOptions}
onSelect={onBranchChange}
disabled={branchLocked || !hasRepo || branchesLoading || branchOptions.length === 0}
disabledReason={computeBranchDisabledReason({
branchLocked: !!branchLocked,
hasRepo,
branchesLoading,
optionCount: branchOptions.length,
})}
searchPlaceholder="Search branches..."
emptyMessage="No branches"
testId="branch-chip-trigger"
tooltip="Base branch"
onRefresh={refreshBranches}
refreshing={branchesLoading}
filter={scoreBranch}
flat
/>
<button
type="button"
onClick={onRemove}
aria-label="Remove repository"
className="h-7 w-6 inline-flex items-center justify-center rounded-md text-muted-foreground hover:text-destructive hover:bg-muted cursor-pointer"
data-testid="remove-repo-chip"
>
<IconX className="h-3 w-3" />
</button>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={onRemove}
aria-label="Remove repository"
className="h-6 w-6 inline-flex items-center justify-center rounded text-muted-foreground hover:text-destructive hover:bg-muted/60 cursor-pointer"
data-testid="remove-repo-chip"
>
<IconX className="h-3 w-3" />
</button>
</TooltipTrigger>
<TooltipContent>Remove repository</TooltipContent>
</Tooltip>
</span>
);
}
type PillOption = { value: string; label: string; keywords?: string[] };
function computeBranchPlaceholder(hasRepo: boolean, loading: boolean, optionCount: number): string {
if (!hasRepo) return "branch";
if (loading) return "loading…";
if (optionCount === 0) return "no branches";
return "branch";
function computeBranchDisabledReason({
branchLocked,
hasRepo,
branchesLoading,
optionCount,
}: {
branchLocked: boolean;
hasRepo: boolean;
branchesLoading: boolean;
optionCount: number;
}): string | undefined {
if (branchLocked) {
return "The local executor uses your repository's current checkout, so the branch can't change here. Toggle 'Fork a new branch' to pick a different base.";
}
if (!hasRepo) return "Select a repository first.";
if (branchesLoading) return "Loading branches…";
if (optionCount === 0) return "No branches available for this repository.";
return undefined;
}
function normalizeRepoPath(path: string): string {
@@ -422,188 +499,3 @@ function shortRepoPath(path: string): string {
if (parts.length <= 1) return path;
return parts.slice(-2).join("/");
}
function branchToOption(b: Branch): PillOption {
// Remote branches keep their "origin/" prefix so they're distinguishable
// from local branches with the same short name (e.g. "main" vs "origin/main").
// Without the prefix, the dropdown shows two indistinguishable rows.
const display = b.type === "remote" && b.remote ? `${b.remote}/${b.name}` : b.name;
return { value: display, label: display };
}
/**
* Compact pill trigger that opens a popover with a search list. Auto-widths
* to its content (no `w-full`, no chevron) so multiple pills can sit on one
* line without overlapping or stretching to fill the row.
*/
function Pill({
icon,
value,
placeholder,
options,
onSelect,
disabled = false,
searchPlaceholder,
emptyMessage,
testId,
onRefresh,
refreshing,
}: {
icon: React.ReactNode;
value: string;
placeholder: string;
options: PillOption[];
onSelect: (value: string) => void;
disabled?: boolean;
searchPlaceholder: string;
emptyMessage: string;
testId?: string;
/** Optional refresh action rendered next to the search input. */
onRefresh?: () => void;
/** Show the refresh icon as spinning + disabled while a refresh is in flight. */
refreshing?: boolean;
}) {
const [open, setOpen] = useState(false);
const hasValue = !!value;
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
disabled={disabled}
data-testid={testId}
className={cn(
"h-7 inline-flex items-center gap-1.5 rounded-md px-2.5 text-xs",
"border border-border/60 bg-muted/30",
disabled
? "opacity-50 cursor-not-allowed"
: "hover:bg-muted hover:border-border cursor-pointer",
!hasValue && "text-muted-foreground",
)}
>
{icon}
<span className="truncate max-w-[160px]">{value || placeholder}</span>
</button>
</PopoverTrigger>
<PopoverContent className="w-64 p-0" align="start" portal={false}>
<Command>
<div className="flex items-center gap-1 px-2 pt-1">
<CommandInput placeholder={searchPlaceholder} className="h-9 flex-1" />
{onRefresh && <BranchRefreshButton onRefresh={onRefresh} refreshing={refreshing} />}
</div>
<CommandList>
<CommandEmpty>{emptyMessage}</CommandEmpty>
<CommandGroup>
{options.map((option) => (
<CommandItem
key={option.value}
value={option.value}
keywords={[option.label, ...(option.keywords ?? [])]}
onSelect={() => {
onSelect(option.value);
setOpen(false);
}}
>
{option.label}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
function GitHubUrlSection({
githubUrl,
githubUrlError,
githubBranch,
githubBranches,
githubBranchesLoading,
onGitHubUrlChange,
onGitHubBranchChange,
}: {
githubUrl: string;
githubUrlError: string | null;
githubBranch: string;
githubBranches: Branch[];
githubBranchesLoading: boolean;
onGitHubUrlChange?: (value: string) => void;
onGitHubBranchChange: (value: string) => void;
}) {
// URL mode is always remote-clone-based, so the branch is freely
// selectable regardless of executor type. Local executor's "branch is
// dictated by the user's checkout" rule does not apply here — there is
// no pre-existing local checkout for an arbitrary GitHub URL.
const branchOptions: PillOption[] = useMemo(
() => githubBranches.map(branchToOption),
[githubBranches],
);
const branchPlaceholder = computeBranchPlaceholder(
!!githubUrl.trim(),
githubBranchesLoading,
branchOptions.length,
);
return (
<>
<GitHubUrlPill
githubUrl={githubUrl}
githubUrlError={githubUrlError}
onGitHubUrlChange={onGitHubUrlChange}
/>
<Pill
icon={<IconGitBranch className="h-3 w-3 shrink-0 text-muted-foreground" />}
value={githubBranch}
placeholder={branchPlaceholder}
options={branchOptions}
onSelect={onGitHubBranchChange}
disabled={!githubUrl.trim() || githubBranchesLoading || branchOptions.length === 0}
searchPlaceholder="Search branches..."
emptyMessage="No branches"
testId="branch-chip-trigger"
/>
</>
);
}
function GitHubUrlPill({
githubUrl,
githubUrlError,
onGitHubUrlChange,
}: {
githubUrl: string;
githubUrlError: string | null;
onGitHubUrlChange?: (value: string) => void;
}) {
return (
<div className="relative inline-flex items-center">
<input
type="text"
value={githubUrl}
onChange={(e) => onGitHubUrlChange?.(e.target.value)}
placeholder="github.com/owner/repo"
data-testid="github-url-input"
aria-label="GitHub repository URL"
aria-invalid={!!githubUrlError}
aria-describedby={githubUrlError ? "github-url-error" : undefined}
className={cn(
"h-7 rounded-md px-2.5 text-xs bg-muted/30 border border-border/60",
"outline-none focus:bg-muted focus:border-border placeholder:text-muted-foreground",
githubUrlError && "border-destructive text-destructive",
)}
autoFocus
/>
{githubUrlError && (
<div
id="github-url-error"
role="alert"
className="absolute left-0 top-full mt-1 z-50 rounded-md border bg-popover px-2 py-1 text-[11px] text-destructive shadow-md whitespace-nowrap"
data-testid="github-url-error"
>
{githubUrlError}
</div>
)}
</div>
);
}
@@ -267,7 +267,7 @@ export const InlineTaskName = memo(function InlineTaskName({
onChange={(e) => onChange(e.target.value)}
placeholder="Task name"
data-testid="task-title-input"
className="w-full bg-transparent border border-input outline-none focus-visible:border-ring focus-visible:ring-ring/35 focus-visible:ring-[2px] text-sm font-medium rounded-md px-3 py-2 transition-colors placeholder:text-muted-foreground/70"
className="w-full border border-input bg-input/20 dark:bg-input/30 text-sm font-medium rounded-md px-3 py-2 placeholder:text-muted-foreground/70 outline-none focus-visible:border-ring transition-colors"
/>
);
});
@@ -497,12 +497,12 @@ export function useDialogComputed({
// Sprites / standalone don't yet know how to provision N sibling repos. Gate
// non-worktree options only when 2+ repos are selected; single-repo tasks
// keep the full executor catalogue.
const isMultiRepoSelection =
fs.repositories.filter((r) => r.repositoryId || r.localPath).length > 1;
const selectedRepoCount = fs.repositories.filter((r) => r.repositoryId || r.localPath).length;
const isMultiRepoSelection = selectedRepoCount > 1;
const executorProfileOptions = useExecutorProfileOptions(allExecutorProfiles, {
disabledReasonFor: isMultiRepoSelection ? nonWorktreeDisabledReason : undefined,
});
const executorHint = useExecutorHint(executors, fs.executorId);
const executorHint = useExecutorHint(executors, fs.executorId, selectedRepoCount);
const isLocalExecutor = useIsLocalExecutor(executors, fs.executorId);
const { headerRepositoryOptions } = useRepositoryOptions(repositories, fs.discoveredRepositories);
const agentProfilesLoading = open && !settingsData.agentsLoaded;
@@ -53,9 +53,7 @@ function QueuedEditView({
onKeyDown={onKeyDown}
className={cn(
"min-h-[60px] max-h-[200px] overflow-y-auto resize-none",
"bg-white dark:bg-gray-900",
"border-blue-300 dark:border-blue-700",
"focus:ring-blue-500 focus:border-blue-500",
"bg-background border-border",
)}
placeholder="Enter message content..."
disabled={isSaving}
@@ -90,25 +88,22 @@ type QueuedDisplayViewProps = {
function QueuedDisplayView({ displayContent, onStartEdit, onCancel }: QueuedDisplayViewProps) {
return (
<div className="flex items-center gap-2 px-3 py-2">
<div className="flex items-center gap-2 px-3 py-1.5">
<Tooltip>
<TooltipTrigger asChild>
<div className="flex-shrink-0">
<IconClock className="h-4 w-4 text-blue-600 dark:text-blue-400" />
<div className="flex items-center gap-1.5 flex-shrink-0 text-muted-foreground">
<IconClock className="h-3.5 w-3.5" />
<span className="text-xs font-medium uppercase tracking-wide">Queued</span>
</div>
</TooltipTrigger>
<TooltipContent side="top">
Message queued - will execute when agent completes
</TooltipContent>
<TooltipContent side="top">Will run when the agent completes</TooltipContent>
</Tooltip>
<div className="flex-1 min-w-0 text-blue-700 dark:text-blue-300 truncate">
{displayContent}
</div>
<div className="flex items-center gap-1 flex-shrink-0">
<div className="flex-1 min-w-0 text-foreground/80 truncate">{displayContent}</div>
<div className="flex items-center gap-0.5 flex-shrink-0">
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 hover:bg-blue-100 dark:hover:bg-blue-900"
className="h-6 w-6 p-0 cursor-pointer text-muted-foreground hover:text-foreground"
onClick={onStartEdit}
title="Edit message"
>
@@ -117,7 +112,7 @@ function QueuedDisplayView({ displayContent, onStartEdit, onCancel }: QueuedDisp
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 hover:bg-blue-100 dark:hover:bg-blue-900"
className="h-6 w-6 p-0 cursor-pointer text-muted-foreground hover:text-foreground"
onClick={onCancel}
title="Cancel queued message"
>
@@ -203,12 +198,7 @@ export const QueuedMessageIndicator = forwardRef<
visibleContent.length > 80 ? visibleContent.substring(0, 80) + "..." : visibleContent;
return (
<div
className={cn(
"bg-blue-50 dark:bg-blue-950/20 border border-blue-200 dark:border-blue-800/50",
"rounded-lg text-sm",
)}
>
<div className={cn("bg-muted/40 border-l-2 border-primary/40 rounded-md text-sm")}>
{isEditing ? (
<QueuedEditView
editValue={editValue}
+1 -1
View File
@@ -41,7 +41,7 @@ function QueuedMessageOverlay({
}: QueuedOverlayProps) {
if (!isQueued || !queuedMessage || isArchived) return null;
return (
<div className="flex-shrink-0 border-t border-blue-400/30 bg-card px-3 py-1">
<div className="flex-shrink-0 bg-card px-3 pt-1.5">
<QueuedMessageIndicator
ref={indicatorRef}
content={queuedMessage.content}
@@ -0,0 +1,54 @@
import { test, expect } from "../../fixtures/test-base";
import { KanbanPage } from "../../pages/kanban-page";
test.describe("Kanban topbar utilities", () => {
test("settings is reachable directly from the topbar (no dropdown)", async ({ testPage }) => {
// Lock to desktop width — on tablet/mobile Settings lives inside the hamburger sheet.
await testPage.setViewportSize({ width: 1280, height: 800 });
const kanban = new KanbanPage(testPage);
await kanban.goto();
const settingsButton = testPage.getByRole("link", { name: "Settings" });
await expect(settingsButton).toBeVisible();
await settingsButton.click();
await expect(testPage).toHaveURL(/\/settings/);
});
test("system health button is hidden when there are no issues", async ({ testPage, backend }) => {
await testPage.route(`${backend.baseUrl}/api/v1/system/health`, (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ healthy: true, issues: [] }),
}),
);
const kanban = new KanbanPage(testPage);
await kanban.goto();
// The button only renders when there are issues; assert it stays hidden.
await expect(testPage.getByRole("button", { name: "Setup Issues" })).toHaveCount(0);
});
test("system health button is visible when there are issues", async ({ testPage, backend }) => {
// Lock to desktop width — on mobile the health button lives inside the closed hamburger sheet.
await testPage.setViewportSize({ width: 1280, height: 800 });
await testPage.route(`${backend.baseUrl}/api/v1/system/health`, (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
healthy: false,
issues: [{ title: "DB offline", severity: "error" }],
}),
}),
);
const kanban = new KanbanPage(testPage);
await kanban.goto();
await expect(testPage.getByRole("button", { name: "Setup Issues" })).toBeVisible();
});
});
@@ -33,9 +33,10 @@ export type UseBranchesResult = {
branches: Branch[];
isLoading: boolean;
/**
* Force-refreshes the branch list with a `git fetch` first. Only available
* for id-based sources (workspace-imported repos); on-machine path sources
* resolve to `undefined` since the refresh endpoint takes a repository id.
* Refreshes the branch list. For id-based sources the backend runs
* `git fetch` first (force-refresh). For path-based sources we re-issue
* the standard list call — there's no fetch endpoint for unimported
* folders, but re-reading still surfaces newly created local branches.
*/
refresh?: () => Promise<void>;
};
@@ -77,10 +78,13 @@ export function useBranches(source: BranchSource | null, enabled = true): UseBra
}, [enabled, isLoaded, key, setRepositoryBranches, setRepositoryBranchesLoading]);
const refresh = useCallback(async () => {
if (!source || source.kind !== "id") return;
if (!source) return;
setRepositoryBranchesLoading(key, true);
try {
const response = await listRepositoryBranches(source.repositoryId, { refresh: true });
const response =
source.kind === "id"
? await listRepositoryBranches(source.repositoryId, { refresh: true })
: await listBranches(source.workspaceId, { path: source.path });
setRepositoryBranches(key, response.branches);
} catch {
// Refresh failures leave the existing branch list in place; the user
@@ -95,6 +99,6 @@ export function useBranches(source: BranchSource | null, enabled = true): UseBra
return {
branches,
isLoading,
refresh: source?.kind === "id" ? refresh : undefined,
refresh: source ? refresh : undefined,
};
}
+8 -2
View File
@@ -73,11 +73,17 @@ export function useReleaseNotes() {
}, [settingsLoaded, lastSeenVersion, storeApi]);
const markAsSeen = useCallback(() => {
const version = latestRelease.version;
// Mark against the newest version in the full changelog rather than the
// current build's release-notes version. In dev/CI those can diverge —
// git-cliff backfills the changelog from unreleased commits while
// release-notes.json is pinned to `git describe --tags --abbrev=0`. If we
// marked against the build version, getUnseenEntries would still see the
// backfilled entries as newer and the topbar dot would never clear.
const version = changelog[0]?.version ?? latestRelease.version;
const { userSettings, setUserSettings } = storeApi.getState();
setUserSettings({ ...userSettings, releaseNotesLastSeenVersion: version });
persistLastSeenVersion(version);
}, [latestRelease.version, storeApi]);
}, [changelog, latestRelease.version, storeApi]);
const openDialog = useCallback(() => {
setDialogEntries(unseenEntries);