feat(web): polish workbench topbars and task-create dialog (#792)
This commit is contained in:
@@ -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();
|
||||
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user