From baaf7d593a507e12871b100983526c8f37f350bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Sun, 3 May 2026 16:15:28 +0100 Subject: [PATCH] feat(web): polish workbench topbars and task-create dialog (#792) --- .../integrations/integrations-menu.test.ts | 28 ++ .../integrations/integrations-menu.tsx | 26 ++ apps/web/components/kanban/kanban-header.tsx | 129 ++---- .../components/kanban/mobile-menu-sheet.tsx | 22 +- apps/web/components/page-topbar.tsx | 28 +- .../quick-chat/quick-chat-content.tsx | 2 +- .../release-notes/release-notes-button.tsx | 6 +- .../task-create-dialog-github-url.tsx | 128 ++++++ .../task-create-dialog-options.test.tsx | 38 ++ .../components/task-create-dialog-options.tsx | 48 +- .../task-create-dialog-pill.test.tsx | 86 ++++ .../components/task-create-dialog-pill.tsx | 254 +++++++++++ .../task-create-dialog-repo-chips.test.tsx | 29 +- .../task-create-dialog-repo-chips.tsx | 418 +++++++----------- .../task-create-dialog-selectors.tsx | 2 +- .../components/task-create-dialog-state.ts | 6 +- .../task/chat/queued-message-indicator.tsx | 32 +- apps/web/components/task/task-chat-panel.tsx | 2 +- .../kanban/kanban-topbar-utilities.spec.ts | 54 +++ .../workspace/use-repository-branches.ts | 16 +- apps/web/hooks/use-release-notes.ts | 10 +- 21 files changed, 922 insertions(+), 442 deletions(-) create mode 100644 apps/web/components/task-create-dialog-github-url.tsx create mode 100644 apps/web/components/task-create-dialog-options.test.tsx create mode 100644 apps/web/components/task-create-dialog-pill.test.tsx create mode 100644 apps/web/components/task-create-dialog-pill.tsx create mode 100644 apps/web/e2e/tests/kanban/kanban-topbar-utilities.spec.ts diff --git a/apps/web/components/integrations/integrations-menu.test.ts b/apps/web/components/integrations/integrations-menu.test.ts index 031e881f9d..119edc6993 100644 --- a/apps/web/components/integrations/integrations-menu.test.ts +++ b/apps/web/components/integrations/integrations-menu.test.ts @@ -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[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(); + }); +}); diff --git a/apps/web/components/integrations/integrations-menu.tsx b/apps/web/components/integrations/integrations-menu.tsx index 382608b021..13731296a7 100644 --- a/apps/web/components/integrations/integrations-menu.tsx +++ b/apps/web/components/integrations/integrations-menu.tsx @@ -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 ( + + + + + {link.label} + + ); + })} + + ); +} + export function MobileIntegrationsSection({ onNavigate }: MobileIntegrationsSectionProps) { const links = useConfiguredIntegrationLinks(); diff --git a/apps/web/components/kanban/kanban-header.tsx b/apps/web/components/kanban/kanban-header.tsx index 3d90b69ff9..08302ecfbd 100644 --- a/apps/web/components/kanban/kanban-header.tsx +++ b/apps/web/components/kanban/kanban-header.tsx @@ -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["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["size"] }) { return ( - - - - - - Utilities - {showReleaseNotesButton && ( - - - Release notes - - )} - - - {showHealthIndicator ? "Health issues" : "System health"} - - {showStatsLink && ( - <> - - - - - Stats - - - - )} - - + + + + + Settings + ); } @@ -181,17 +121,27 @@ function ImproveKandevTopbarButton({ ); } +function StatsTopbarButton() { + return ( + + + + + Stats + + ); +} + function HomeLeftActions({ workspaceId }: { workspaceId?: string }) { return ( <> - + + - ); } @@ -199,8 +149,8 @@ function HomeLeftActions({ workspaceId }: { workspaceId?: string }) { function WorkspaceLeftActions({ workspaceId }: { workspaceId?: string }) { return ( <> + - ); } @@ -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" /> - + {showReleaseNotesButton && ( + + )} + } /> @@ -494,6 +446,7 @@ export function KanbanHeader({ const indicatorProps = { showReleaseNotesButton: releaseNotes.showTopbarButton, + releaseNotesHasUnseen: releaseNotes.hasUnseen, onOpenReleaseNotes: releaseNotes.openDialog, showHealthIndicator: healthIndicator.hasIssues, onOpenHealthDialog: healthIndicator.openDialog, diff --git a/apps/web/components/kanban/mobile-menu-sheet.tsx b/apps/web/components/kanban/mobile-menu-sheet.tsx index a4042220aa..d21c979072 100644 --- a/apps/web/components/kanban/mobile-menu-sheet.tsx +++ b/apps/web/components/kanban/mobile-menu-sheet.tsx @@ -269,17 +269,17 @@ function MobileUtilityActions({ Release notes )} - + {showHealthIndicator && ( + + )} + ); + + // A disabled + /> )} {onToggleGitHubUrl && ( + {freshBranchToggle} + + + + + + + {addHint ?? "Add another repository"} + ); } +function FreshBranchToggle({ + enabled, + onToggle, +}: { + enabled: boolean; + onToggle: (enabled: boolean) => void; +}) { + return ( + + + + + + {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."} + + + ); +} + +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 { const ids = new Set(); @@ -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 ( @@ -376,40 +426,67 @@ function RepoChip({ searchPlaceholder="Search repositories..." emptyMessage="No repositories" testId="repo-chip-trigger" + tooltip={repoTooltip} + flat /> } - 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 /> - + + + + + Remove repository + ); } -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 ( - - - - - - -
- - {onRefresh && } -
- - {emptyMessage} - - {options.map((option) => ( - { - onSelect(option.value); - setOpen(false); - }} - > - {option.label} - - ))} - - -
-
-
- ); -} - -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 ( - <> - - } - 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 ( -
- 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 && ( - - )} -
- ); -} diff --git a/apps/web/components/task-create-dialog-selectors.tsx b/apps/web/components/task-create-dialog-selectors.tsx index 8e66e30f5a..4a08a293ec 100644 --- a/apps/web/components/task-create-dialog-selectors.tsx +++ b/apps/web/components/task-create-dialog-selectors.tsx @@ -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" /> ); }); diff --git a/apps/web/components/task-create-dialog-state.ts b/apps/web/components/task-create-dialog-state.ts index 37d3e90fc2..20d127a64c 100644 --- a/apps/web/components/task-create-dialog-state.ts +++ b/apps/web/components/task-create-dialog-state.ts @@ -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; diff --git a/apps/web/components/task/chat/queued-message-indicator.tsx b/apps/web/components/task/chat/queued-message-indicator.tsx index de7cb338c6..5c979deb6d 100644 --- a/apps/web/components/task/chat/queued-message-indicator.tsx +++ b/apps/web/components/task/chat/queued-message-indicator.tsx @@ -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 ( -
+
-
- +
+ + Queued
- - Message queued - will execute when agent completes - + Will run when the agent completes -
- {displayContent} -
-
+
{displayContent}
+