feat: refine workbench chrome (#761)

* docs: define product and design direction

* style: establish restrained workbench primitives

* refactor: declutter board and task topbars

* style: align dockview panel chrome

* fix: align task topbar vcs control sizing

* refactor: surface integrations navigation

* fix: repair integrations and task creation workflow

* refactor: include linear in integrations navigation

* style: restore familiar workbench tones

* fix: add dockview tab breathing room

* fix: show configured integration shortcuts

* fix: hover open integrations menu

* fix: stabilize integrations hover menu

* fix: align home and dockview topbars

* fix: normalize home topbar controls

* fix: compact home topbar height

* fix: simplify home chrome and split buttons

* fix: move integrations to left chrome

* fix: restore home stats shortcut

* fix: add dockview tab breathing room

* fix: border home stats button

* fix: streamline dockview topbar actions

* fix: align task split button seam

* fix: address review feedback

* fix: restore topbar e2e affordances

* fix: stabilize workflow e2e isolation

* fix: add task topbar action overflow

* fix: address PR review feedback
This commit is contained in:
José Almeida
2026-05-01 21:02:41 +01:00
committed by GitHub
parent c8fcd6c5aa
commit 68c94dfa3a
48 changed files with 2615 additions and 890 deletions
+78
View File
@@ -0,0 +1,78 @@
# Design
## Direction
Kandev should read as a restrained developer workbench. The shell is dense and quiet, with panels, command surfaces, and state indicators arranged so users can keep their place across tasks, sessions, repositories, and integrations.
The primary scene is a developer switching between agent output, git state, review context, files, terminals, preview panels, and external systems during focused code work. The design should reduce glare, avoid ornamental chrome, and keep work state legible for long sessions.
## Color
Use OKLCH tokens for theme colors. Neutrals should be lightly tinted rather than pure grayscale, with lower chroma near white and near black to prevent glare.
### Theme Strategy
- Light theme: low-glare tinted surfaces, not pure white. Use subtle panel separation through borders and slight surface shifts.
- Dark theme: low-glare charcoal neutrals, not pure black. Preserve contrast in code, terminal, and diff views without neon accents.
- Accent: one primary accent for focus rings, selected navigation, active segmented controls, and the primary command in a scope.
- Status palette: semantic success, warning, danger, info, running, pending, archived, and remote states. Status must include icon or text support so color is never the only signal.
- Data and diffs: keep git addition and deletion colors consistent across cards, file trees, Monaco, Pierre diffs, and terminal-adjacent UI.
### Token Contract
Use the existing shadcn variable shape as the contract: `--background`, `--foreground`, `--card`, `--popover`, `--muted`, `--border`, `--input`, `--ring`, `--primary`, `--accent`, `--destructive`, chart tokens, sidebar tokens, and git diff tokens.
Future token work should normalize app and package themes so `apps/web/app/globals.css`, `apps/packages/theme/src/globals.css`, `apps/web/lib/theme/colors.ts`, editor themes, and terminal themes stay in sync.
## Typography
Use `Figtree` as the preferred UI family, falling back to Geist and system UI. Use `Monaspace Neon` first for code and terminal surfaces, falling back to `Geist Mono`, platform monospace, and standard monospace.
- Product UI type should stay compact and fixed, not viewport-fluid.
- Page and panel headings should be modest. Reserve larger type for settings page titles and empty states, not dense work surfaces.
- Code, shell, log, branch, path, SHA, and command values should use the mono stack.
- Numeric status and counts should use tabular numerals where alignment matters.
## Layout
The shell should be panel-first. Panels, sidebars, topbars, and dock headers define the working frame; content should not be wrapped in decorative cards unless a repeated item, modal, or tool needs a frame.
- Use a unified topbar hierarchy: global orientation first, active task/session/workflow state second, primary scoped command third, secondary utilities last.
- Avoid nested cards and floating section cards. Prefer full-width bands, split panes, table rows, list rows, and dock panels.
- Keep gutters tight but intentional. Dense areas need clear alignment more than extra whitespace.
- Preserve stable dimensions for toolbars, panel tabs, icon buttons, counters, and segmented controls so labels and loading states do not shift layout.
- Prefer command menus, split buttons, segmented controls, popovers, and contextual panel headers over broad action strips.
## Components
Use shadcn components from `@kandev/ui`. Do not import shadcn primitives from `@/components/ui/*`.
- Buttons: primary for the main scoped command; outline or ghost for secondary actions; icon buttons with tooltips for compact utilities.
- Menus: use for secondary commands, view options, scripts, layouts, and integration actions.
- Segmented controls: use for mutually exclusive view modes or range filters.
- Inputs: search and command inputs should be direct and compact, with loading and dirty states visible.
- Badges: use for counts and semantic state, not decoration.
- Tables and lists: use dense rows with clear hover, selected, focus, empty, and loading states.
- Panels: panel headers own panel-specific actions such as split, close, add panel, run script, open preview, or collapse side regions.
- Dialogs and sheets: reserve for creation flows, confirmations, complex configuration, and mobile overflow navigation.
## Motion
Motion should be short, functional, and reversible.
- Use 150-250 ms transitions for hover, menu, sheet, popover, and panel reveal states.
- Respect reduced motion. Disable nonessential animation when users request reduced motion.
- Avoid decorative motion and page-load choreography.
- Loading motion should be limited to spinners, skeletons, progress, or status indicators where it communicates state.
## Responsive Chrome
Desktop should show the full workbench: global chrome, task/session topbar, dock panels, contextual panel headers, and secondary action menus.
Tablet should preserve orientation and the primary command, then collapse secondary actions into menus or sheets. Search can narrow, but must remain reachable when it is a core workflow.
Mobile should prioritize a small topbar with current product orientation, urgent status, and a menu trigger. View filters, display controls, settings links, and noncritical navigation belong in a sheet. Task/session work should keep primary commands reachable without forcing horizontal overflow.
## Accessibility
Target WCAG AA across light and dark themes. Every icon-only control needs an accessible name or tooltip. Keyboard focus must remain visible in topbars, menus, popovers, dialogs, dock headers, and panel controls. Status must be identifiable without color. Reduced motion should keep workflows usable with no loss of meaning.
+55
View File
@@ -0,0 +1,55 @@
# Product
## Users
Kandev is for developers and engineering teams using AI agents for code work, reviews, workflows, and repository automation. Users arrive with active engineering context: tasks in flight, sessions running, worktrees diverging, repositories changing, integrations reporting state, and follow-up decisions waiting on them.
## Product Purpose
Kandev coordinates agentic development work across tasks, sessions, worktrees, repositories, and integrations. The product should help users understand what is happening, choose the next command, and move work through a workflow without losing trust in the underlying developer environment.
Success means users can quickly answer:
- What task, repository, branch, session, and workflow step am I in?
- What changed, what is running, and what needs attention?
- Which commands are available now, and which are secondary or conditional?
- How does this agent-driven work connect back to GitHub, Jira, editors, terminals, and local or remote execution?
## Brand Personality
Focused, technical, composed.
The interface should feel like a reliable developer workbench: direct, legible, and confident under pressure. It should avoid performative futurism and keep the user oriented in real engineering state.
## Anti-references
- Generic SaaS dashboards with decorative metrics and inflated spacing.
- Decorative AI gradients, glowing blobs, and "magic" visual language.
- Cluttered IDE chrome that competes with the actual work.
- Novelty terminal cosplay, fake hacker styling, and retro effects used as decoration.
## Design Principles
### Orientation before controls
The user should first understand their current scope: workspace, repository, task, session, branch, workflow step, and execution state. Controls follow orientation instead of crowding it.
### Command-first secondary actions
Primary actions should be obvious and state-aware. Secondary actions should be available through predictable command surfaces, menus, panel headers, or contextual toolbars rather than scattered across global chrome.
### Density without clutter
Kandev is a professional tool with real state density. Use compact controls and information-rich rows, but preserve grouping, alignment, and hierarchy so the surface can be scanned without fatigue.
### State visible but calm
Running agents, remote execution, git status, reviews, tunnels, health, and workflow progress should be visible without becoming alarmist. Color and motion indicate meaning, not decoration.
### Familiar affordances preserve developer trust
Use recognizable developer patterns for navigation, git, terminals, panels, editors, filters, and settings. Novel interaction should be reserved for places where the workflow genuinely needs it.
## Accessibility & Inclusion
Target WCAG AA. Interaction must remain reachable by keyboard, with visible focus states and screen-reader labels for icon-only controls. Status must not depend on color alone; pair color with labels, icons, tooltips, or text. Support reduced motion by avoiding nonessential animation and keeping state transitions short.
+47 -2
View File
@@ -26,6 +26,21 @@
--color-input: var(--input); --color-input: var(--input);
--color-border: var(--border); --color-border: var(--border);
--color-destructive: var(--destructive); --color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-success: var(--success);
--color-success-foreground: var(--success-foreground);
--color-warning: var(--warning);
--color-warning-foreground: var(--warning-foreground);
--color-info: var(--info);
--color-info-foreground: var(--info-foreground);
--color-running: var(--running);
--color-running-foreground: var(--running-foreground);
--color-pending: var(--pending);
--color-pending-foreground: var(--pending-foreground);
--color-remote: var(--remote);
--color-remote-foreground: var(--remote-foreground);
--color-archived: var(--archived);
--color-archived-foreground: var(--archived-foreground);
--color-accent-foreground: var(--accent-foreground); --color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent); --color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground); --color-muted-foreground: var(--muted-foreground);
@@ -68,6 +83,21 @@
--accent: oklch(0.68 0.18 276); --accent: oklch(0.68 0.18 276);
--accent-foreground: oklch(0.98 0 0); --accent-foreground: oklch(0.98 0 0);
--destructive: oklch(0.58 0.22 27); --destructive: oklch(0.58 0.22 27);
--destructive-foreground: oklch(0.96 0.02 27);
--success: oklch(0.58 0.12 150);
--success-foreground: oklch(0.982 0.01 150);
--warning: oklch(0.7 0.13 78);
--warning-foreground: oklch(0.23 0.035 72);
--info: oklch(0.58 0.12 205);
--info-foreground: oklch(0.982 0.01 205);
--running: var(--primary);
--running-foreground: var(--primary-foreground);
--pending: oklch(0.556 0 0);
--pending-foreground: oklch(0.985 0 0);
--remote: oklch(0.57 0.11 185);
--remote-foreground: oklch(0.982 0.01 185);
--archived: oklch(0.556 0 0);
--archived-foreground: oklch(0.985 0 0);
--border: oklch(0.922 0 0); --border: oklch(0.922 0 0);
--input: oklch(0.922 0 0); --input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0); --ring: oklch(0.708 0 0);
@@ -103,6 +133,21 @@
--accent: oklch(0.62 0.18 276); --accent: oklch(0.62 0.18 276);
--accent-foreground: oklch(0.98 0 0); --accent-foreground: oklch(0.98 0 0);
--destructive: oklch(0.704 0.191 22.216); --destructive: oklch(0.704 0.191 22.216);
--destructive-foreground: oklch(0.985 0 0);
--success: oklch(0.68 0.12 150);
--success-foreground: oklch(0.16 0.025 150);
--warning: oklch(0.78 0.12 78);
--warning-foreground: oklch(0.19 0.035 72);
--info: oklch(0.7 0.11 205);
--info-foreground: oklch(0.16 0.025 205);
--running: var(--primary);
--running-foreground: var(--primary-foreground);
--pending: oklch(0.708 0 0);
--pending-foreground: oklch(0.145 0 0);
--remote: oklch(0.68 0.11 185);
--remote-foreground: oklch(0.16 0.025 185);
--archived: oklch(0.708 0 0);
--archived-foreground: oklch(0.145 0 0);
--border: oklch(1 0 0 / 10%); --border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%); --input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0); --ring: oklch(0.556 0 0);
@@ -139,9 +184,9 @@
@keyframes kanban-task-pulse { @keyframes kanban-task-pulse {
0%, 0%,
100% { 100% {
box-shadow: 0 0 0 0 rgba(79, 70, 229, 0.12); box-shadow: 0 0 0 0 color-mix(in oklab, var(--primary) 12%, transparent);
} }
50% { 50% {
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.22); box-shadow: 0 0 0 3px color-mix(in oklab, var(--primary) 18%, transparent);
} }
} }
+1 -1
View File
@@ -5,7 +5,7 @@ import { Slot } from "radix-ui";
import { cn } from "./lib/utils"; import { cn } from "./lib/utils";
const badgeVariants = cva( const badgeVariants = cva(
"h-5 gap-1 rounded-full border border-transparent px-2 py-0.5 text-[0.625rem] font-medium transition-all has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&>svg]:size-2.5! inline-flex items-center justify-center w-fit whitespace-nowrap shrink-0 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-colors overflow-hidden group/badge", "h-5 gap-1 rounded-md border border-transparent px-2 py-0.5 text-[0.625rem] font-medium transition-colors has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&>svg]:size-2.5! inline-flex items-center justify-center w-fit whitespace-nowrap shrink-0 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/35 focus-visible:ring-[2px] aria-invalid:border-destructive aria-invalid:ring-destructive/25 aria-invalid:ring-[2px] data-[disabled=true]:opacity-45 overflow-hidden group/badge",
{ {
variants: { variants: {
variant: { variant: {
+1 -1
View File
@@ -5,7 +5,7 @@ import { Slot } from "radix-ui";
import { cn } from "./lib/utils"; import { cn } from "./lib/utils";
const buttonVariants = cva( const buttonVariants = cva(
"focus-visible:border-ring focus-visible:ring-ring/30 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-md border border-transparent bg-clip-padding text-xs/relaxed font-medium focus-visible:ring-[2px] aria-invalid:ring-[2px] [&_svg:not([class*='size-'])]:size-4 inline-flex items-center justify-center whitespace-nowrap transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none shrink-0 [&_svg]:shrink-0 outline-none group/button select-none", "focus-visible:border-ring focus-visible:ring-ring/35 aria-invalid:border-destructive aria-invalid:ring-destructive/25 rounded-md border border-transparent bg-clip-padding text-xs/relaxed font-medium focus-visible:ring-[2px] aria-invalid:ring-[2px] [&_svg:not([class*='size-'])]:size-4 inline-flex cursor-pointer items-center justify-center whitespace-nowrap transition-colors disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-45 [&_svg]:pointer-events-none shrink-0 [&_svg]:shrink-0 outline-none group/button select-none",
{ {
variants: { variants: {
variant: { variant: {
+3 -3
View File
@@ -12,7 +12,7 @@ function Card({
data-slot="card" data-slot="card"
data-size={size} data-size={size}
className={cn( className={cn(
"ring-foreground/10 bg-card text-card-foreground gap-4 overflow-hidden rounded-lg py-4 text-xs/relaxed ring-1 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 *:[img:first-child]:rounded-t-lg *:[img:last-child]:rounded-b-lg group/card flex flex-col", "border-border bg-card text-card-foreground gap-4 overflow-hidden rounded-md border py-4 text-xs/relaxed has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 *:[img:first-child]:rounded-t-md *:[img:last-child]:rounded-b-md group/card flex flex-col",
className, className,
)} )}
{...props} {...props}
@@ -25,7 +25,7 @@ function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
<div <div
data-slot="card-header" data-slot="card-header"
className={cn( className={cn(
"gap-1 rounded-t-lg px-4 group-data-[size=sm]/card:px-3 [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3 group/card-header @container/card-header grid auto-rows-min items-start has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto]", "gap-1 px-4 group-data-[size=sm]/card:px-3 [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3 group/card-header @container/card-header grid auto-rows-min items-start has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto]",
className, className,
)} )}
{...props} {...props}
@@ -72,7 +72,7 @@ function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
<div <div
data-slot="card-footer" data-slot="card-footer"
className={cn( className={cn(
"rounded-b-lg px-4 group-data-[size=sm]/card:px-3 [.border-t]:pt-4 group-data-[size=sm]/card:[.border-t]:pt-3 flex items-center", "px-4 group-data-[size=sm]/card:px-3 [.border-t]:pt-4 group-data-[size=sm]/card:[.border-t]:pt-3 flex items-center",
className, className,
)} )}
{...props} {...props}
+3 -3
View File
@@ -44,7 +44,7 @@ function DialogOverlay({
<DialogPrimitive.Overlay <DialogPrimitive.Overlay
data-slot="dialog-overlay" data-slot="dialog-overlay"
className={cn( className={cn(
"data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 duration-100 supports-backdrop-filter:backdrop-blur-xs fixed inset-0 isolate z-50", "bg-background/70 data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 duration-100 fixed inset-0 isolate z-50",
className, className,
)} )}
{...props} {...props}
@@ -68,7 +68,7 @@ function DialogContent({
<DialogPrimitive.Content <DialogPrimitive.Content
data-slot="dialog-content" data-slot="dialog-content"
className={cn( className={cn(
"bg-card data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 ring-foreground/10 grid max-w-[calc(100%-2rem)] gap-4 rounded-xl p-4 text-xs/relaxed ring-1 duration-100 sm:max-w-lg fixed top-1/2 left-1/2 z-50 w-full -translate-x-1/2 -translate-y-1/2", "border-border bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 grid max-w-[calc(100%-2rem)] gap-4 rounded-lg border p-4 text-xs/relaxed shadow-md duration-100 sm:max-w-lg fixed top-1/2 left-1/2 z-50 w-full -translate-x-1/2 -translate-y-1/2",
className, className,
)} )}
{...props} {...props}
@@ -104,7 +104,7 @@ function DialogFooter({
return ( return (
<div <div
data-slot="dialog-footer" data-slot="dialog-footer"
className={cn("gap-2 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)} className={cn("gap-2 flex flex-col-reverse sm:flex-row sm:justify-end", className)}
{...props} {...props}
> >
{children} {children}
+1 -1
View File
@@ -8,7 +8,7 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
type={type} type={type}
data-slot="input" data-slot="input"
className={cn( className={cn(
"bg-input/20 dark:bg-input/30 border-input focus-visible:border-ring focus-visible:ring-ring/30 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 h-7 rounded-md border px-2 py-0.5 text-sm transition-colors file:h-6 file:text-xs/relaxed file:font-medium focus-visible:ring-[2px] aria-invalid:ring-[2px] md:text-xs/relaxed file:text-foreground placeholder:text-muted-foreground w-full min-w-0 outline-none file:inline-flex file:border-0 file:bg-transparent disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50", "border-input bg-background hover:bg-secondary/50 focus-visible:border-ring focus-visible:ring-ring/35 aria-invalid:border-destructive aria-invalid:ring-destructive/25 h-7 rounded-md border px-2 py-0.5 text-sm transition-colors file:h-6 file:text-xs/relaxed file:font-medium focus-visible:ring-[2px] aria-invalid:ring-[2px] md:text-xs/relaxed file:text-foreground placeholder:text-muted-foreground w-full min-w-0 outline-none file:inline-flex file:border-0 file:bg-transparent disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-muted disabled:opacity-55",
className, className,
)} )}
{...props} {...props}
+3 -3
View File
@@ -37,7 +37,7 @@ function SelectTrigger({
data-slot="select-trigger" data-slot="select-trigger"
data-size={size} data-size={size}
className={cn( className={cn(
"border-input data-[placeholder]:text-muted-foreground bg-input/20 dark:bg-input/30 dark:hover:bg-input/50 focus-visible:border-ring focus-visible:ring-ring/30 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 gap-1.5 rounded-md border px-2 py-1.5 text-xs/relaxed transition-colors focus-visible:ring-[2px] aria-invalid:ring-[2px] data-[size=default]:h-7 data-[size=sm]:h-6 *:data-[slot=select-value]:flex *:data-[slot=select-value]:gap-1.5 [&_svg:not([class*='size-'])]:size-3.5 flex w-fit items-center justify-between whitespace-nowrap outline-none disabled:cursor-not-allowed disabled:opacity-50 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center [&_svg]:pointer-events-none [&_svg]:shrink-0", "border-input data-[placeholder]:text-muted-foreground bg-background hover:bg-secondary/50 focus-visible:border-ring focus-visible:ring-ring/35 aria-invalid:border-destructive aria-invalid:ring-destructive/25 gap-1.5 rounded-md border px-2 py-1.5 text-xs/relaxed transition-colors focus-visible:ring-[2px] aria-invalid:ring-[2px] data-[size=default]:h-7 data-[size=sm]:h-6 *:data-[slot=select-value]:flex *:data-[slot=select-value]:gap-1.5 [&_svg:not([class*='size-'])]:size-3.5 flex w-fit cursor-pointer items-center justify-between whitespace-nowrap outline-none disabled:cursor-not-allowed disabled:bg-muted disabled:opacity-55 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center [&_svg]:pointer-events-none [&_svg]:shrink-0",
className, className,
)} )}
{...props} {...props}
@@ -62,7 +62,7 @@ function SelectContent({
<SelectPrimitive.Content <SelectPrimitive.Content
data-slot="select-content" data-slot="select-content"
className={cn( className={cn(
"bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 min-w-32 rounded-lg shadow-md ring-1 duration-100 relative z-50 max-h-(--radix-select-content-available-height) origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto", "border-border bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 min-w-32 rounded-md border shadow-sm duration-100 relative z-50 max-h-(--radix-select-content-available-height) origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto",
position === "popper" && position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className, className,
@@ -106,7 +106,7 @@ function SelectItem({
<SelectPrimitive.Item <SelectPrimitive.Item
data-slot="select-item" data-slot="select-item"
className={cn( className={cn(
"focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground min-h-7 gap-2 rounded-md px-2 py-1 text-xs/relaxed [&_svg:not([class*='size-'])]:size-3.5 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2 relative flex w-full cursor-default items-center outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0", "focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground min-h-7 gap-2 rounded-md px-2 py-1 text-xs/relaxed [&_svg:not([class*='size-'])]:size-3.5 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2 relative flex w-full cursor-pointer items-center outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:cursor-not-allowed data-[disabled]:opacity-45 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className, className,
)} )}
{...props} {...props}
+3 -3
View File
@@ -34,7 +34,7 @@ function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return ( return (
<tfoot <tfoot
data-slot="table-footer" data-slot="table-footer"
className={cn("bg-muted/50 border-t font-medium [&>tr]:last:border-b-0", className)} className={cn("bg-muted/60 border-t font-medium [&>tr]:last:border-b-0", className)}
{...props} {...props}
/> />
); );
@@ -45,7 +45,7 @@ function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
<tr <tr
data-slot="table-row" data-slot="table-row"
className={cn( className={cn(
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors", "hover:bg-accent/55 focus-within:bg-accent/55 data-[state=selected]:bg-accent/70 border-b transition-colors data-[disabled=true]:opacity-45",
className, className,
)} )}
{...props} {...props}
@@ -58,7 +58,7 @@ function TableHead({ className, ...props }: React.ComponentProps<"th">) {
<th <th
data-slot="table-head" data-slot="table-head"
className={cn( className={cn(
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0", "text-muted-foreground h-9 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0",
className, className,
)} )}
{...props} {...props}
+2 -2
View File
@@ -40,13 +40,13 @@ function TooltipContent({
data-slot="tooltip-content" data-slot="tooltip-content"
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( className={cn(
"pointer-events-none data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 rounded-md px-3 py-1.5 text-xs **:data-[slot=kbd]:rounded-md bg-foreground text-background z-50 w-fit max-w-xs origin-(--radix-tooltip-content-transform-origin)", "border-border bg-popover text-popover-foreground pointer-events-none data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 rounded-md border px-3 py-1.5 text-xs shadow-sm **:data-[slot=kbd]:rounded-md z-50 w-fit max-w-xs origin-(--radix-tooltip-content-transform-origin)",
className, className,
)} )}
{...props} {...props}
> >
{children} {children}
<TooltipPrimitive.Arrow className="size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground z-50 translate-y-[calc(-50%_-_2px)]" /> <TooltipPrimitive.Arrow className="bg-popover fill-popover size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] z-50" />
</TooltipPrimitive.Content> </TooltipPrimitive.Content>
</TooltipPrimitive.Portal> </TooltipPrimitive.Portal>
); );
+48 -46
View File
@@ -1,30 +1,23 @@
/* Dockview theme — Mux-inspired pill tabs */ /* Dockview workbench chrome */
.dockview-theme-kandev { .dockview-theme-kandev {
/* Overall dockview bg (visible in gaps between groups) — match card to eliminate visible gaps */
--dv-background-color: var(--card); --dv-background-color: var(--card);
/* Each group panel area */
--dv-group-view-background-color: var(--background); --dv-group-view-background-color: var(--background);
/* Separator / sash */
--dv-separator-border: var(--border); --dv-separator-border: var(--border);
--dv-active-sash-color: var(--primary); --dv-active-sash-color: var(--primary);
--dv-paneview-header-border-color: var(--border); --dv-paneview-header-border-color: var(--border);
/* Tab bar — #171717 bg, matching panel content */
--dv-tabs-and-actions-container-font-size: 12px; --dv-tabs-and-actions-container-font-size: 12px;
--dv-tabs-and-actions-container-height: 40px;
--dv-tabs-and-actions-container-background-color: var(--background); --dv-tabs-and-actions-container-background-color: var(--background);
/* Active group, selected tab — #2a2a2a pill */
--dv-activegroup-visiblepanel-tab-background-color: var(--muted); --dv-activegroup-visiblepanel-tab-background-color: var(--muted);
--dv-activegroup-visiblepanel-tab-color: var(--foreground); --dv-activegroup-visiblepanel-tab-color: var(--foreground);
/* Active group, unselected tab — transparent */
--dv-activegroup-hiddenpanel-tab-background-color: transparent; --dv-activegroup-hiddenpanel-tab-background-color: transparent;
--dv-activegroup-hiddenpanel-tab-color: var(--muted-foreground); --dv-activegroup-hiddenpanel-tab-color: var(--muted-foreground);
/* Inactive group tabs — more dimmed */
--dv-inactivegroup-visiblepanel-tab-background-color: color-mix( --dv-inactivegroup-visiblepanel-tab-background-color: color-mix(
in srgb, in srgb,
var(--muted) 100%, var(--muted) 100%,
@@ -42,48 +35,38 @@
transparent transparent
); );
/* Close icon */
--dv-tab-close-icon: var(--muted-foreground); --dv-tab-close-icon: var(--muted-foreground);
/* Misc */
--dv-tabs-container-scrollbar-color: var(--scrollbar-thumb); --dv-tabs-container-scrollbar-color: var(--scrollbar-thumb);
--dv-drag-over-background-color: color-mix(in oklab, var(--primary) 10%, transparent); --dv-drag-over-background-color: color-mix(in oklab, var(--primary) 10%, transparent);
--dv-drag-over-border-color: var(--primary); --dv-drag-over-border-color: var(--primary);
} }
/* ── Inactive group tab bar dimming ── */
.dockview-theme-kandev .inactive-group .dv-tabs-and-actions-container {
/* opacity: 0.6; */
}
/* ── Tab bar container ── */
.dockview-theme-kandev .dv-tabs-and-actions-container { .dockview-theme-kandev .dv-tabs-and-actions-container {
background: var(--background); background: var(--background);
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
min-height: 34px; min-height: 40px;
height: 34px; height: 40px;
padding: 0 6px; padding: 7px 5px 5px;
align-items: center; align-items: center;
} }
/* Remove tab divider separators */
.dockview-theme-kandev .dv-tabs-container.dv-horizontal .dv-tab::before { .dockview-theme-kandev .dv-tabs-container.dv-horizontal .dv-tab::before {
display: none !important; display: none !important;
} }
/* ── Individual tabs — pill style ── */
.dockview-theme-kandev .dv-tab { .dockview-theme-kandev .dv-tab {
padding: 0; padding: 0;
margin: 0 2px; margin: 0 1px;
font-size: 12px; font-size: 12px;
font-weight: 500; font-weight: 500;
min-width: auto; min-width: auto;
border-radius: 6px; border-radius: 5px;
border-bottom: none !important; border-bottom: none !important;
outline: none !important; outline: none;
transition: transition:
background-color 0.15s ease, background-color 0.15s ease,
color 0.15s ease; color 0.15s ease;
cursor: pointer;
} }
.dockview-theme-kandev .dv-tab:hover { .dockview-theme-kandev .dv-tab:hover {
@@ -91,28 +74,38 @@
color: var(--foreground); color: var(--foreground);
} }
/* Active tab — filled pill, no underline */
.dockview-theme-kandev .dv-active-tab { .dockview-theme-kandev .dv-active-tab {
border-bottom: none !important; border-bottom: none !important;
} }
/* ── Default tab (built-in with close button) ── */ .dockview-theme-kandev .dv-tab:focus-within,
.dockview-theme-kandev .dv-tab:focus-visible {
box-shadow:
inset 0 0 0 1px var(--ring),
0 0 0 1px color-mix(in oklab, var(--ring) 20%, transparent);
}
.dockview-theme-kandev .dv-default-tab { .dockview-theme-kandev .dv-default-tab {
font-size: 12px; font-size: 12px;
font-weight: 500; font-weight: 500;
padding: 2px 6px; padding: 0 7px;
gap: 6px; gap: 5px;
height: 24px;
line-height: 1;
} }
/* Close button — show on hover and active */
.dockview-theme-kandev .dv-default-tab .dv-default-tab-action { .dockview-theme-kandev .dv-default-tab .dv-default-tab-action {
opacity: 0; opacity: 0;
transition: opacity 0.15s ease; transition:
border-radius: 3px; opacity 0.15s ease,
background-color 0.15s ease,
color 0.15s ease;
border-radius: 4px;
padding: 2px; padding: 2px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
cursor: pointer;
} }
.dockview-theme-kandev .dv-default-tab .dv-default-tab-action .dv-svg { .dockview-theme-kandev .dv-default-tab .dv-default-tab-action .dv-svg {
@@ -122,6 +115,12 @@
.dockview-theme-kandev .dv-default-tab .dv-default-tab-action:hover { .dockview-theme-kandev .dv-default-tab .dv-default-tab-action:hover {
background: color-mix(in srgb, var(--foreground) 12%, transparent); background: color-mix(in srgb, var(--foreground) 12%, transparent);
color: var(--foreground);
}
.dockview-theme-kandev .dv-default-tab .dv-default-tab-action:focus-visible {
outline: 1px solid var(--ring);
outline-offset: 1px;
} }
.dockview-theme-kandev .dv-tab:hover .dv-default-tab .dv-default-tab-action, .dockview-theme-kandev .dv-tab:hover .dv-default-tab .dv-default-tab-action,
@@ -129,12 +128,10 @@
opacity: 1; opacity: 1;
} }
/* ── Panel content ── */
.dockview-theme-kandev .dv-content-container { .dockview-theme-kandev .dv-content-container {
background: var(--card); background: var(--card);
} }
/* Strip all chrome from session panel wrappers inside dockview */
.dockview-theme-kandev .dv-content-container .session-panel-content-wrapper { .dockview-theme-kandev .dv-content-container .session-panel-content-wrapper {
border-radius: 0 !important; border-radius: 0 !important;
box-shadow: none !important; box-shadow: none !important;
@@ -147,7 +144,6 @@
margin: 0 !important; margin: 0 !important;
} }
/* ── Sash (resize handle) ── */
.dockview-theme-kandev .dv-resize-container > .sash-container .sash { .dockview-theme-kandev .dv-resize-container > .sash-container .sash {
background: transparent; background: transparent;
transition: background-color 0.15s ease; transition: background-color 0.15s ease;
@@ -158,43 +154,49 @@
background: var(--primary); background: var(--primary);
} }
/* Hide close button on tabs in locked groups (sidebar) */
.dockview-theme-kandev .dv-locked-groupview .dv-default-tab .dv-default-tab-action { .dockview-theme-kandev .dv-locked-groupview .dv-default-tab .dv-default-tab-action {
display: none !important; display: none !important;
} }
/* ── Left header actions (after last tab — "+" button) ── */
.dockview-theme-kandev .dv-left-actions-container { .dockview-theme-kandev .dv-left-actions-container {
display: flex; display: flex;
align-items: center; align-items: center;
padding-left: 4px; padding-left: 3px;
gap: 2px; gap: 2px;
} }
.dv-tab:focus-within:after, .dockview-theme-kandev .dv-tab:focus-within::after,
.dv-tab:focus:after { .dockview-theme-kandev .dv-tab:focus::after {
outline: none !important; outline: none !important;
} }
/* ── Right header actions ── */
.dockview-theme-kandev .dv-right-actions-container { .dockview-theme-kandev .dv-right-actions-container {
display: flex; display: flex;
align-items: center; align-items: center;
padding-right: 4px; padding-right: 3px;
gap: 2px; gap: 2px;
} }
/* ── Void container (spacer between left-actions and right-actions) ── */ .dockview-theme-kandev .dv-left-actions-container button,
.dockview-theme-kandev .dv-right-actions-container button {
border-radius: 5px;
cursor: pointer;
}
.dockview-theme-kandev .dv-left-actions-container button:focus-visible,
.dockview-theme-kandev .dv-right-actions-container button:focus-visible {
outline: 1px solid var(--ring);
outline-offset: 1px;
}
.dockview-theme-kandev .dv-void-container { .dockview-theme-kandev .dv-void-container {
flex: 1; flex: 1;
} }
/* ── Watermark (empty group) ── */
.dockview-theme-kandev .dv-watermark { .dockview-theme-kandev .dv-watermark {
background: transparent; background: transparent;
} }
/* ── Drop target overlay ── */
.dockview-theme-kandev .drop-target-dropzone > .drop-target-selection { .dockview-theme-kandev .drop-target-dropzone > .drop-target-selection {
border: 2px solid var(--primary); border: 2px solid var(--primary);
background: color-mix(in oklab, var(--primary) 8%, transparent); background: color-mix(in oklab, var(--primary) 8%, transparent);
+1 -1
View File
@@ -64,7 +64,7 @@ function NotAuthenticatedNotice() {
<AlertDescription> <AlertDescription>
GitHub is not connected. Configure GitHub authentication (gh CLI or a Personal Access Token) GitHub is not connected. Configure GitHub authentication (gh CLI or a Personal Access Token)
in{" "} in{" "}
<Link href="/settings" className="underline font-medium"> <Link href="/settings" className="underline font-medium cursor-pointer">
Settings → GitHub Settings → GitHub
</Link>{" "} </Link>{" "}
to see your pull requests and issues. to see your pull requests and issues.
+122 -106
View File
@@ -34,6 +34,21 @@
--color-input: var(--input); --color-input: var(--input);
--color-border: var(--border); --color-border: var(--border);
--color-destructive: var(--destructive); --color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-success: var(--success);
--color-success-foreground: var(--success-foreground);
--color-warning: var(--warning);
--color-warning-foreground: var(--warning-foreground);
--color-info: var(--info);
--color-info-foreground: var(--info-foreground);
--color-running: var(--running);
--color-running-foreground: var(--running-foreground);
--color-pending: var(--pending);
--color-pending-foreground: var(--pending-foreground);
--color-remote: var(--remote);
--color-remote-foreground: var(--remote-foreground);
--color-archived: var(--archived);
--color-archived-foreground: var(--archived-foreground);
--color-accent-foreground: var(--accent-foreground); --color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent); --color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground); --color-muted-foreground: var(--muted-foreground);
@@ -76,6 +91,21 @@
--accent: oklch(0.68 0.18 276); --accent: oklch(0.68 0.18 276);
--accent-foreground: oklch(0.98 0 0); --accent-foreground: oklch(0.98 0 0);
--destructive: oklch(0.58 0.22 27); --destructive: oklch(0.58 0.22 27);
--destructive-foreground: oklch(0.96 0.02 27);
--success: oklch(0.58 0.12 150);
--success-foreground: oklch(0.982 0.01 150);
--warning: oklch(0.7 0.13 78);
--warning-foreground: oklch(0.23 0.035 72);
--info: oklch(0.58 0.12 205);
--info-foreground: oklch(0.982 0.01 205);
--running: var(--primary);
--running-foreground: var(--primary-foreground);
--pending: oklch(0.556 0 0);
--pending-foreground: oklch(0.985 0 0);
--remote: oklch(0.57 0.11 185);
--remote-foreground: oklch(0.982 0.01 185);
--archived: oklch(0.556 0 0);
--archived-foreground: oklch(0.985 0 0);
--border: oklch(0.922 0 0); --border: oklch(0.922 0 0);
--input: oklch(0.922 0 0); --input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0); --ring: oklch(0.708 0 0);
@@ -95,15 +125,13 @@
--scrollbar-thumb: oklch(0.78 0 0); --scrollbar-thumb: oklch(0.78 0 0);
--scrollbar-thumb-hover: oklch(0.66 0 0); --scrollbar-thumb-hover: oklch(0.66 0 0);
--scrollbar-track: var(--background); --scrollbar-track: var(--background);
--sidebar-ring: oklch(0.708 0 0); --sidebar-ring: var(--ring);
/* Git diff colors - centralized for easy maintenance */ --git-addition: 63 185 80;
--git-addition: 63 185 80; /* #3fb950 - GitHub addition green */ --git-deletion: 244 112 103;
--git-deletion: 244 112 103; /* #f47067 - GitHub deletion red */
} }
.dark { .dark {
/* Grey scale: #181818 bg → #1F1F1F card → #2a2a2a borders → #333 hover */
--background: #181818; --background: #181818;
--foreground: #d4d4d4; --foreground: #d4d4d4;
--card: #1f1f1f; --card: #1f1f1f;
@@ -119,6 +147,21 @@
--accent: oklch(0.62 0.18 276); --accent: oklch(0.62 0.18 276);
--accent-foreground: #d4d4d4; --accent-foreground: #d4d4d4;
--destructive: oklch(0.704 0.191 22.216); --destructive: oklch(0.704 0.191 22.216);
--destructive-foreground: #d4d4d4;
--success: oklch(0.68 0.12 150);
--success-foreground: oklch(0.16 0.025 150);
--warning: oklch(0.78 0.12 78);
--warning-foreground: oklch(0.19 0.035 72);
--info: oklch(0.7 0.11 205);
--info-foreground: oklch(0.16 0.025 205);
--running: var(--primary);
--running-foreground: var(--primary-foreground);
--pending: oklch(0.708 0 0);
--pending-foreground: oklch(0.145 0 0);
--remote: oklch(0.68 0.11 185);
--remote-foreground: oklch(0.16 0.025 185);
--archived: oklch(0.708 0 0);
--archived-foreground: oklch(0.145 0 0);
--border: #2a2a2a; --border: #2a2a2a;
--input: #2a2a2a; --input: #2a2a2a;
--ring: #555555; --ring: #555555;
@@ -137,11 +180,10 @@
--scrollbar-thumb: rgba(100, 100, 100, 0.5); --scrollbar-thumb: rgba(100, 100, 100, 0.5);
--scrollbar-thumb-hover: rgba(130, 130, 130, 0.6); --scrollbar-thumb-hover: rgba(130, 130, 130, 0.6);
--scrollbar-track: var(--background); --scrollbar-track: var(--background);
--sidebar-ring: #555555; --sidebar-ring: var(--ring);
/* Git diff colors - same as light mode for consistency */ --git-addition: 63 185 80;
--git-addition: 63 185 80; /* #3fb950 - GitHub addition green */ --git-deletion: 244 112 103;
--git-deletion: 244 112 103; /* #f47067 - GitHub deletion red */
} }
@layer base { @layer base {
@@ -162,12 +204,12 @@
*::-webkit-scrollbar-track { *::-webkit-scrollbar-track {
background: var(--scrollbar-track); background: var(--scrollbar-track);
border-radius: 999px; border-radius: 8px;
} }
*::-webkit-scrollbar-thumb { *::-webkit-scrollbar-thumb {
background: var(--scrollbar-thumb); background: var(--scrollbar-thumb);
border-radius: 999px; border-radius: 8px;
border: 2px solid transparent; border: 2px solid transparent;
background-clip: padding-box; background-clip: padding-box;
} }
@@ -186,7 +228,7 @@
.xterm .xterm-scrollable-element > .scrollbar > .slider { .xterm .xterm-scrollable-element > .scrollbar > .slider {
background: var(--scrollbar-thumb) !important; background: var(--scrollbar-thumb) !important;
border-radius: 999px !important; border-radius: 8px !important;
width: 6px !important; width: 6px !important;
left: 2px !important; left: 2px !important;
} }
@@ -386,16 +428,16 @@
@keyframes chat-input-running-plan { @keyframes chat-input-running-plan {
0%, 0%,
100% { 100% {
border-color: color-mix(in oklab, oklch(0.65 0.2 300) 40%, var(--border)); border-color: color-mix(in oklab, var(--primary) 40%, var(--border));
box-shadow: box-shadow:
0 0 6px 1px color-mix(in oklab, oklch(0.65 0.2 300) 12%, transparent), 0 0 6px 1px color-mix(in oklab, var(--primary) 12%, transparent),
0 0 15px 3px color-mix(in oklab, oklch(0.65 0.2 300) 6%, transparent); 0 0 15px 3px color-mix(in oklab, var(--primary) 6%, transparent);
} }
50% { 50% {
border-color: color-mix(in oklab, oklch(0.65 0.2 300) 70%, var(--border)); border-color: color-mix(in oklab, var(--primary) 70%, var(--border));
box-shadow: box-shadow:
0 0 20px 4px color-mix(in oklab, oklch(0.65 0.2 300) 20%, transparent), 0 0 20px 4px color-mix(in oklab, var(--primary) 20%, transparent),
0 0 50px 8px color-mix(in oklab, oklch(0.65 0.2 300) 10%, transparent); 0 0 50px 8px color-mix(in oklab, var(--primary) 10%, transparent);
} }
} }
@@ -452,9 +494,9 @@
.cm-tooltip { .cm-tooltip {
border: 1px solid var(--border) !important; border: 1px solid var(--border) !important;
background: var(--popover, oklch(0.205 0 0)) !important; background: var(--popover, oklch(0.205 0 0)) !important;
color: var(--popover-foreground, oklch(0.985 0 0)) !important; color: var(--popover-foreground, oklch(0.91 0.014 230)) !important;
border-radius: calc(var(--radius) + 2px); border-radius: calc(var(--radius) + 2px);
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.25); box-shadow: 0 12px 32px color-mix(in oklab, var(--foreground) 18%, transparent);
padding: 8px; padding: 8px;
} }
@@ -479,7 +521,7 @@
border-radius: 6px; border-radius: 6px;
cursor: pointer; cursor: pointer;
font-size: 0.875rem; font-size: 0.875rem;
color: var(--popover-foreground, oklch(0.985 0 0)); color: var(--popover-foreground, oklch(0.91 0.014 230));
} }
.cm-tooltip-autocomplete li[aria-selected="true"] { .cm-tooltip-autocomplete li[aria-selected="true"] {
@@ -537,13 +579,13 @@
} }
.cm-completionIcon-slash { .cm-completionIcon-slash {
mask-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><path d='M7 9a2 2 0 1 1 2 -2v10a2 2 0 1 1 -2 -2h10a2 2 0 1 1 -2 2v-10a2 2 0 1 1 2 2h-10'/></svg>"); mask-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><path d='M7 9a2 2 0 1 1 2 -2v10a2 2 0 1 1 -2 -2h10a2 2 0 1 1 -2 2v-10a2 2 0 1 1 2 2h-10'/></svg>");
-webkit-mask-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><path d='M7 9a2 2 0 1 1 2 -2v10a2 2 0 1 1 -2 -2h10a2 2 0 1 1 -2 2v-10a2 2 0 1 1 2 2h-10'/></svg>"); -webkit-mask-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><path d='M7 9a2 2 0 1 1 2 -2v10a2 2 0 1 1 -2 -2h10a2 2 0 1 1 -2 2v-10a2 2 0 1 1 2 2h-10'/></svg>");
} }
.cm-completionIcon-mention { .cm-completionIcon-mention {
mask-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><path d='M8 12a4 4 0 1 0 8 0a4 4 0 1 0 -8 0'/><path d='M16 12v1.5a2.5 2.5 0 0 0 5 0v-1.5a9 9 0 1 0 -5.5 8.28'/></svg>"); mask-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><path d='M8 12a4 4 0 1 0 8 0a4 4 0 1 0 -8 0'/><path d='M16 12v1.5a2.5 2.5 0 0 0 5 0v-1.5a9 9 0 1 0 -5.5 8.28'/></svg>");
-webkit-mask-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><path d='M8 12a4 4 0 1 0 8 0a4 4 0 1 0 -8 0'/><path d='M16 12v1.5a2.5 2.5 0 0 0 5 0v-1.5a9 9 0 1 0 -5.5 8.28'/></svg>"); -webkit-mask-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><path d='M8 12a4 4 0 1 0 8 0a4 4 0 1 0 -8 0'/><path d='M16 12v1.5a2.5 2.5 0 0 0 5 0v-1.5a9 9 0 1 0 -5.5 8.28'/></svg>");
} }
.cm-completionIcon-prompt { .cm-completionIcon-prompt {
@@ -594,7 +636,7 @@
/* Legacy Milkdown styles removed - plan editor now uses TipTap */ /* Legacy Milkdown styles removed - plan editor now uses TipTap */
/* ── Shared markdown body styles ── /* Shared markdown body styles
Single source of truth for rendered markdown across TipTap (plan editor) Single source of truth for rendered markdown across TipTap (plan editor)
and ReactMarkdown (chat, PR, changelog, etc.). */ and ReactMarkdown (chat, PR, changelog, etc.). */
.markdown-body { .markdown-body {
@@ -673,7 +715,7 @@
margin-bottom: 0.25rem; margin-bottom: 0.25rem;
} }
/* Inline code — accent (indigo) color */ /* Inline code accent color */
.markdown-body :not(pre) > code, .markdown-body :not(pre) > code,
.markdown-body code:not(pre code) { .markdown-body code:not(pre code) {
background: var(--muted); background: var(--muted);
@@ -749,7 +791,7 @@
font-weight: 700; font-weight: 700;
} }
/* User message markdown overrides — adjust for primary background */ /* User message markdown overrides for primary background */
.markdown-body-user :not(pre) > code, .markdown-body-user :not(pre) > code,
.markdown-body-user code:not(pre code) { .markdown-body-user code:not(pre code) {
background: color-mix(in oklch, var(--primary) 20%, transparent); background: color-mix(in oklch, var(--primary) 20%, transparent);
@@ -780,7 +822,7 @@
/* Code blocks */ /* Code blocks */
.tiptap pre { .tiptap pre {
background-color: color-mix(in oklch, var(--card) 80%, black 20%); background-color: color-mix(in oklch, var(--card) 92%, var(--foreground) 8%);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 0.375rem; border-radius: 0.375rem;
padding: 0.5rem 0.75rem; padding: 0.5rem 0.75rem;
@@ -831,7 +873,7 @@
.tiptap pre code .hljs-keyword, .tiptap pre code .hljs-keyword,
.tiptap pre code .hljs-selector-tag, .tiptap pre code .hljs-selector-tag,
.tiptap pre code .hljs-built_in { .tiptap pre code .hljs-built_in {
color: oklch(0.65 0.2 300); color: var(--primary);
} }
.tiptap pre code .hljs-string, .tiptap pre code .hljs-string,
@@ -848,17 +890,17 @@
.tiptap pre code .hljs-title, .tiptap pre code .hljs-title,
.tiptap pre code .hljs-section, .tiptap pre code .hljs-section,
.tiptap pre code .hljs-type { .tiptap pre code .hljs-type {
color: oklch(0.75 0.15 80); color: oklch(0.62 0.11 198);
} }
.tiptap pre code .hljs-name, .tiptap pre code .hljs-name,
.tiptap pre code .hljs-tag { .tiptap pre code .hljs-tag {
color: oklch(0.65 0.2 20); color: var(--destructive);
} }
.tiptap pre code .hljs-variable, .tiptap pre code .hljs-variable,
.tiptap pre code .hljs-template-variable { .tiptap pre code .hljs-template-variable {
color: oklch(0.65 0.2 20); color: oklch(0.62 0.12 48);
} }
.tiptap pre code .hljs-regexp, .tiptap pre code .hljs-regexp,
@@ -876,7 +918,7 @@
} }
.tiptap pre code .hljs-deletion { .tiptap pre code .hljs-deletion {
color: oklch(0.65 0.2 20); color: var(--destructive);
} }
.tiptap pre code .hljs-emphasis { .tiptap pre code .hljs-emphasis {
@@ -889,28 +931,28 @@
/* Monaco Editor - Comment decorations */ /* Monaco Editor - Comment decorations */
.monaco-comment-line { .monaco-comment-line {
background-color: rgba(99, 102, 241, 0.12) !important; background-color: color-mix(in oklab, var(--primary) 12%, transparent) !important;
} }
/* Lines-decoration lane for commented lines — clickable bar */ /* Lines-decoration lane for commented lines, clickable bar */
.monaco-comment-bar { .monaco-comment-bar {
cursor: pointer !important; cursor: pointer !important;
background-color: rgba(99, 102, 241, 0.12) !important; background-color: color-mix(in oklab, var(--primary) 12%, transparent) !important;
border-left: 3px solid rgba(99, 102, 241, 0.6) !important; border-left: 3px solid color-mix(in oklab, var(--primary) 58%, transparent) !important;
} }
.monaco-comment-bar:hover { .monaco-comment-bar:hover {
background-color: rgba(99, 102, 241, 0.25) !important; background-color: color-mix(in oklab, var(--primary) 20%, transparent) !important;
} }
/* Chat bubble icon on the bar (first line of a comment only) */ /* Chat bubble icon on the bar (first line of a comment only) */
.monaco-comment-bar-icon { .monaco-comment-bar-icon {
cursor: pointer !important; cursor: pointer !important;
position: relative !important; position: relative !important;
background-color: rgba(99, 102, 241, 0.12) !important; background-color: color-mix(in oklab, var(--primary) 12%, transparent) !important;
border-left: 3px solid rgba(99, 102, 241, 0.6) !important; border-left: 3px solid color-mix(in oklab, var(--primary) 58%, transparent) !important;
} }
.monaco-comment-bar-icon:hover { .monaco-comment-bar-icon:hover {
background-color: rgba(99, 102, 241, 0.25) !important; background-color: color-mix(in oklab, var(--primary) 20%, transparent) !important;
} }
.monaco-comment-bar-icon::after { .monaco-comment-bar-icon::after {
content: ""; content: "";
@@ -920,16 +962,16 @@
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
width: 12px; width: 12px;
height: 12px; height: 12px;
background: rgba(99, 102, 241, 0.9); background: var(--primary);
mask-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><path d='M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z'/></svg>"); mask-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><path d='M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z'/></svg>");
-webkit-mask-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><path d='M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z'/></svg>"); -webkit-mask-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><path d='M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z'/></svg>");
mask-size: contain; mask-size: contain;
-webkit-mask-size: contain; -webkit-mask-size: contain;
} }
/* Line number bg tint for commented lines */ /* Line number bg tint for commented lines */
.monaco-comment-line-number { .monaco-comment-line-number {
background-color: rgba(99, 102, 241, 0.12) !important; background-color: color-mix(in oklab, var(--primary) 12%, transparent) !important;
} }
/* TipTap Plan Editor Styles /* TipTap Plan Editor Styles
@@ -942,7 +984,7 @@
} }
.tiptap-plan-wrapper:not(.dark) { .tiptap-plan-wrapper:not(.dark) {
background: #ffffff; background: var(--background);
} }
.tiptap-plan-wrapper .tiptap { .tiptap-plan-wrapper .tiptap {
@@ -983,29 +1025,29 @@
} }
.tiptap-plan-wrapper:not(.dark) .tiptap pre { .tiptap-plan-wrapper:not(.dark) .tiptap pre {
background: #f5f5f5 !important; background: var(--muted) !important;
} }
.tiptap-plan-wrapper .tiptap pre code { .tiptap-plan-wrapper .tiptap pre code {
background: transparent !important; background: transparent !important;
color: #d4d4d4; color: var(--foreground);
padding: 0 !important; padding: 0 !important;
font-size: var(--font-mono-size) !important; font-size: var(--font-mono-size) !important;
font-family: var(--font-mono) !important; font-family: var(--font-mono) !important;
} }
.tiptap-plan-wrapper:not(.dark) .tiptap pre code { .tiptap-plan-wrapper:not(.dark) .tiptap pre code {
color: #1e1e1e; color: var(--foreground);
} }
/* Highlight marks */ /* Highlight marks */
.tiptap-plan-wrapper mark { .tiptap-plan-wrapper mark {
background-color: rgba(255, 212, 0, 0.3); background-color: color-mix(in oklab, oklch(0.84 0.12 92) 38%, transparent);
border-radius: 2px; border-radius: 2px;
padding: 1px 0; padding: 1px 0;
} }
/* Comment highlights — inline mark spans (like Highlight extension) */ /* Comment highlights, inline mark spans like Highlight extension */
.tiptap-plan-wrapper .comment-highlight { .tiptap-plan-wrapper .comment-highlight {
background-color: color-mix(in oklch, var(--accent) 70%, transparent); background-color: color-mix(in oklch, var(--accent) 70%, transparent);
border-radius: 3px; border-radius: 3px;
@@ -1021,7 +1063,7 @@
background-color: color-mix(in oklch, var(--accent) 90%, transparent); background-color: color-mix(in oklch, var(--accent) 90%, transparent);
} }
/* Comment badge — single widget decoration at end of mark range */ /* Comment badge, single widget decoration at end of mark range */
.tiptap-plan-wrapper .comment-badge { .tiptap-plan-wrapper .comment-badge {
position: relative; position: relative;
display: inline-block; display: inline-block;
@@ -1037,14 +1079,14 @@
left: 2px; left: 2px;
width: 11px; width: 11px;
height: 11px; height: 11px;
color: rgba(99, 102, 241, 0.8); color: color-mix(in oklab, var(--primary) 80%, var(--foreground));
cursor: pointer; cursor: pointer;
transition: color 0.15s ease; transition: color 0.15s ease;
pointer-events: auto; pointer-events: auto;
} }
.tiptap-plan-wrapper .comment-badge:hover svg { .tiptap-plan-wrapper .comment-badge:hover svg {
color: rgba(99, 102, 241, 1); color: var(--primary);
} }
/* Drag handle */ /* Drag handle */
@@ -1091,7 +1133,7 @@
} }
.tiptap-plan-wrapper .mermaid-error { .tiptap-plan-wrapper .mermaid-error {
color: #ef4444 !important; color: var(--destructive) !important;
font-size: 0.75rem !important; font-size: 0.75rem !important;
padding: 0.5rem !important; padding: 0.5rem !important;
white-space: pre-wrap !important; white-space: pre-wrap !important;
@@ -1114,7 +1156,7 @@
} }
.tiptap-plan-wrapper .tiptap pre.mermaid-code-view .mm-keyword { .tiptap-plan-wrapper .tiptap pre.mermaid-code-view .mm-keyword {
color: oklch(0.65 0.2 300); color: var(--primary);
} }
.tiptap-plan-wrapper .tiptap pre.mermaid-code-view .mm-arrow { .tiptap-plan-wrapper .tiptap pre.mermaid-code-view .mm-arrow {
@@ -1130,7 +1172,7 @@
} }
.tiptap-plan-wrapper:not(.dark) .tiptap pre.mermaid-code-view .mm-keyword { .tiptap-plan-wrapper:not(.dark) .tiptap pre.mermaid-code-view .mm-keyword {
color: oklch(0.45 0.2 280); color: var(--primary);
} }
.tiptap-plan-wrapper:not(.dark) .tiptap pre.mermaid-code-view .mm-arrow { .tiptap-plan-wrapper:not(.dark) .tiptap pre.mermaid-code-view .mm-arrow {
@@ -1151,8 +1193,7 @@
padding: 0.125rem 0.375rem; padding: 0.125rem 0.375rem;
border-radius: 0.375rem; border-radius: 0.375rem;
border: 1px solid oklch(from var(--border) l c h / 0.5); border: 1px solid oklch(from var(--border) l c h / 0.5);
background: oklch(from var(--background) l c h / 0.8); background: var(--popover);
backdrop-filter: blur(4px);
opacity: 0; opacity: 0;
transition: opacity 0.15s; transition: opacity 0.15s;
z-index: 10; z-index: 10;
@@ -1249,17 +1290,17 @@
font-size: 11px; font-size: 11px;
font-weight: 700; font-weight: 700;
line-height: 1; line-height: 1;
color: #fff; color: var(--primary-foreground);
border-radius: 3px; border-radius: 3px;
background-color: rgb(99 102 241); /* indigo-500 */ background-color: var(--primary);
} }
/* Monaco Editor - Gutter line selection highlight */ /* Monaco Editor - Gutter line selection highlight */
.monaco-gutter-selected-line { .monaco-gutter-selected-line {
background-color: rgb(139 92 246 / 0.15) !important; background-color: color-mix(in oklab, var(--primary) 15%, transparent) !important;
} }
.monaco-gutter-selected-deco { .monaco-gutter-selected-deco {
background-color: rgb(139 92 246 / 0.15) !important; background-color: color-mix(in oklab, var(--primary) 15%, transparent) !important;
} }
/* Monaco Editor - Peek view / references widget overrides */ /* Monaco Editor - Peek view / references widget overrides */
@@ -1301,48 +1342,23 @@
} }
/* Pipeline node rotating border for running tasks */ /* Pipeline node rotating border for running tasks */
@property --border-angle { @keyframes node-border-pulse {
syntax: "<angle>"; 0%,
initial-value: 0deg; 100% {
inherits: false; box-shadow:
} 0 0 0 1px color-mix(in oklab, var(--primary) 42%, var(--border)),
0 0 0 0 color-mix(in oklab, var(--primary) 0%, transparent);
@keyframes node-border-rotate { }
to { 50% {
--border-angle: 360deg; box-shadow:
0 0 0 1px color-mix(in oklab, var(--primary) 68%, var(--border)),
0 0 0 3px color-mix(in oklab, var(--primary) 12%, transparent);
} }
} }
.node-border-running { .node-border-running {
position: relative; position: relative;
} animation: node-border-pulse 2.8s ease-in-out infinite;
.node-border-running::after {
content: "";
position: absolute;
inset: -1px;
border-radius: inherit;
padding: 1.5px;
background: conic-gradient(
from var(--border-angle),
hsl(var(--muted-foreground) / 0.4) 0%,
transparent 12%,
transparent 38%,
hsl(var(--muted-foreground) / 0.4) 50%,
transparent 62%,
transparent 88%,
hsl(var(--muted-foreground) / 0.4) 100%
);
-webkit-mask:
linear-gradient(#fff 0 0) content-box,
linear-gradient(#fff 0 0);
mask:
linear-gradient(#fff 0 0) content-box,
linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
animation: node-border-rotate 3s linear infinite;
pointer-events: none;
} }
.chat-message-list { .chat-message-list {
@@ -1368,17 +1384,17 @@
} }
.debug { .debug {
border: 1px solid red !important; border: 1px solid var(--destructive) !important;
} }
/* Panel search highlights */ /* Panel search highlights */
.search-highlight { .search-highlight {
background: color-mix(in srgb, var(--color-yellow-300, #fde047) 65%, transparent); background: color-mix(in oklab, oklch(0.86 0.13 92) 42%, transparent);
border-radius: 2px; border-radius: 2px;
} }
.search-highlight-current { .search-highlight-current {
background: var(--color-orange-400, #fb923c); background: color-mix(in oklab, var(--warning) 56%, transparent);
outline: 1px solid var(--color-orange-600, #ea580c); outline: 1px solid color-mix(in oklab, var(--warning) 78%, var(--border));
border-radius: 2px; border-radius: 2px;
} }
.search-flash { .search-flash {
@@ -1386,7 +1402,7 @@
} }
@keyframes search-flash { @keyframes search-flash {
0% { 0% {
background-color: color-mix(in srgb, var(--color-orange-300, #fdba74) 70%, transparent); background-color: color-mix(in oklab, var(--warning) 48%, transparent);
} }
100% { 100% {
background-color: transparent; background-color: transparent;
+1 -1
View File
@@ -43,7 +43,7 @@ function NotConfiguredNotice({ workspaceId }: { workspaceId?: string }) {
Jira is not configured for this workspace.{" "} Jira is not configured for this workspace.{" "}
<Link <Link
href={workspaceId ? `/settings/workspace/${workspaceId}/jira` : "/settings"} href={workspaceId ? `/settings/workspace/${workspaceId}/jira` : "/settings"}
className="underline font-medium" className="underline font-medium cursor-pointer"
> >
Configure Jira Configure Jira
</Link>{" "} </Link>{" "}
+4 -2
View File
@@ -7,6 +7,7 @@ import { ToggleGroup, ToggleGroupItem } from "@kandev/ui/toggle-group";
import type { StatsResponse } from "@/lib/types/http"; import type { StatsResponse } from "@/lib/types/http";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import { IconChartBar } from "@tabler/icons-react";
import { import {
OverviewCards, OverviewCards,
WorkloadSection, WorkloadSection,
@@ -80,7 +81,7 @@ function getRangeLabel(range: RangeKey): string {
function StatsEmptyState({ message }: { message: string }) { function StatsEmptyState({ message }: { message: string }) {
return ( return (
<div className="h-screen w-full flex flex-col bg-background"> <div className="h-screen w-full flex flex-col bg-background">
<PageTopbar title="Statistics" /> <PageTopbar title="Statistics" icon={<IconChartBar className="h-4 w-4" />} />
<div className="flex-1 flex items-center justify-center"> <div className="flex-1 flex items-center justify-center">
<p className="text-muted-foreground">{message}</p> <p className="text-muted-foreground">{message}</p>
</div> </div>
@@ -100,6 +101,7 @@ function StatsHeader({ global, range, copied, onRangeChange, onCopy }: StatsHead
return ( return (
<PageTopbar <PageTopbar
title="Statistics" title="Statistics"
icon={<IconChartBar className="h-4 w-4" />}
subtitle={`${global.total_tasks} tasks · ${global.total_sessions} sessions · ${formatDuration(global.total_duration_ms)}`} subtitle={`${global.total_tasks} tasks · ${global.total_sessions} sessions · ${formatDuration(global.total_duration_ms)}`}
actions={ actions={
<> <>
@@ -322,7 +324,7 @@ export function StatsPageClient({ stats, error, workspaceId, activeRange }: Stat
if (error) if (error)
return ( return (
<div className="h-screen w-full flex flex-col bg-background"> <div className="h-screen w-full flex flex-col bg-background">
<PageTopbar title="Statistics" /> <PageTopbar title="Statistics" icon={<IconChartBar className="h-4 w-4" />} />
<div className="flex-1 flex items-center justify-center"> <div className="flex-1 flex items-center justify-center">
<p className="text-destructive">Error loading stats: {error}</p> <p className="text-destructive">Error loading stats: {error}</p>
</div> </div>
@@ -0,0 +1,138 @@
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { createElement } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
getAvailableIntegrationLinks,
getGitHubIntegrationStatus,
IntegrationsMenu,
} from "./integrations-menu";
import type { GitHubStatus } from "@/lib/types/github";
const useGitHubStatusMock = vi.hoisted(() => vi.fn());
const useJiraAvailableMock = vi.hoisted(() => vi.fn());
const useLinearAvailableMock = vi.hoisted(() => vi.fn());
vi.mock("@/hooks/domains/github/use-github-status", () => ({
useGitHubStatus: useGitHubStatusMock,
}));
vi.mock("@/components/jira/my-jira/use-jira-availability", () => ({
useJiraAvailable: useJiraAvailableMock,
}));
vi.mock("@/components/linear/use-linear-availability", () => ({
useLinearAvailable: useLinearAvailableMock,
}));
function status(overrides: Partial<GitHubStatus>): GitHubStatus {
return {
authenticated: false,
username: "",
auth_method: "none",
token_configured: false,
required_scopes: [],
...overrides,
};
}
function mockAvailability({
githubReady,
jiraAvailable,
linearAvailable,
}: {
githubReady: boolean;
jiraAvailable: boolean;
linearAvailable: boolean;
}) {
useGitHubStatusMock.mockReturnValue({
status: githubReady ? status({ token_configured: true }) : status({}),
loading: false,
});
useJiraAvailableMock.mockReturnValue(jiraAvailable);
useLinearAvailableMock.mockReturnValue(linearAvailable);
}
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
describe("getGitHubIntegrationStatus", () => {
it("shows checking while GitHub status is loading and not configured", () => {
expect(getGitHubIntegrationStatus(status({}), true)).toEqual({
ready: false,
label: "Checking",
});
});
it("treats a configured token as ready even before live auth is green", () => {
expect(getGitHubIntegrationStatus(status({ token_configured: true }), false)).toEqual({
ready: true,
label: "Configured",
});
});
it("uses the GitHub page for authenticated status", () => {
expect(getGitHubIntegrationStatus(status({ authenticated: true }), false)).toEqual({
ready: true,
label: "Connected",
});
});
it("shows setup only when no auth or token is configured", () => {
expect(getGitHubIntegrationStatus(status({}), false)).toEqual({
ready: false,
label: "Setup",
});
});
});
describe("getAvailableIntegrationLinks", () => {
it("returns only configured integration destinations", () => {
expect(
getAvailableIntegrationLinks({
githubReady: true,
jiraAvailable: false,
linearAvailable: true,
}),
).toEqual([
{ id: "github", label: "GitHub", href: "/github" },
{ id: "linear", label: "Linear", href: "/linear" },
]);
});
it("returns no setup destinations when integrations are unavailable", () => {
expect(
getAvailableIntegrationLinks({
githubReady: false,
jiraAvailable: false,
linearAvailable: false,
}),
).toEqual([]);
});
});
describe("IntegrationsMenu", () => {
it("opens configured integration links on hover", async () => {
mockAvailability({ githubReady: true, jiraAvailable: true, linearAvailable: false });
render(createElement(IntegrationsMenu, { workspaceId: "workspace-1" }));
const trigger = screen.getByRole("button", { name: "Integrations" });
expect(screen.queryByText("GitHub")).toBeNull();
fireEvent.pointerEnter(trigger);
expect(await screen.findByText("GitHub")).toBeTruthy();
expect(screen.getByText("Jira")).toBeTruthy();
expect(screen.queryByText("Linear")).toBeNull();
});
it("does not render when no integrations are configured", () => {
mockAvailability({ githubReady: false, jiraAvailable: false, linearAvailable: false });
render(createElement(IntegrationsMenu, { workspaceId: "workspace-1" }));
expect(screen.queryByRole("button", { name: "Integrations" })).toBeNull();
});
});
@@ -0,0 +1,190 @@
"use client";
import { useEffect, useRef, useState } from "react";
import Link from "next/link";
import { Button } from "@kandev/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
} from "@kandev/ui/dropdown-menu";
import { IconBrandGithub, IconHexagon, IconPlugConnected, IconTicket } from "@tabler/icons-react";
import { useJiraAvailable } from "@/components/jira/my-jira/use-jira-availability";
import { useLinearAvailable } from "@/components/linear/use-linear-availability";
import { useGitHubStatus } from "@/hooks/domains/github/use-github-status";
import type { GitHubStatus } from "@/lib/types/github";
type IntegrationsProps = {
workspaceId?: string;
};
type MobileIntegrationsSectionProps = IntegrationsProps & {
onNavigate: () => void;
};
type IntegrationId = "github" | "jira" | "linear";
type IntegrationLink = {
id: IntegrationId;
label: string;
href: string;
};
type IntegrationAvailability = {
githubReady: boolean;
jiraAvailable: boolean;
linearAvailable: boolean;
};
const INTEGRATION_LINKS: IntegrationLink[] = [
{ id: "github", label: "GitHub", href: "/github" },
{ id: "jira", label: "Jira", href: "/jira" },
{ id: "linear", label: "Linear", href: "/linear" },
];
const INTEGRATION_ICONS = {
github: IconBrandGithub,
jira: IconTicket,
linear: IconHexagon,
} satisfies Record<IntegrationId, typeof IconBrandGithub>;
const HOVER_CLOSE_DELAY_MS = 180;
export function getAvailableIntegrationLinks({
githubReady,
jiraAvailable,
linearAvailable,
}: IntegrationAvailability): IntegrationLink[] {
return INTEGRATION_LINKS.filter((link) => {
if (link.id === "github") return githubReady;
if (link.id === "jira") return jiraAvailable;
return linearAvailable;
});
}
function getStatusLabel(loading: boolean | undefined): string {
return loading ? "Checking" : "Setup";
}
export function getGitHubIntegrationStatus(status: GitHubStatus | null, loading: boolean) {
if (status?.authenticated) return { ready: true, label: "Connected" };
if (status?.token_configured) return { ready: true, label: "Configured" };
return { ready: false, label: getStatusLabel(loading) };
}
function useConfiguredIntegrationLinks(workspaceId: string | undefined): IntegrationLink[] {
const { status, loading } = useGitHubStatus();
const jiraAvailable = useJiraAvailable(workspaceId);
const linearAvailable = useLinearAvailable(workspaceId);
const githubStatus = getGitHubIntegrationStatus(status, loading);
return getAvailableIntegrationLinks({
githubReady: githubStatus.ready,
jiraAvailable,
linearAvailable,
});
}
export function IntegrationsMenu({ workspaceId }: IntegrationsProps) {
const links = useConfiguredIntegrationLinks(workspaceId);
const [open, setOpen] = useState(false);
const closeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
return () => {
if (closeTimeoutRef.current) clearTimeout(closeTimeoutRef.current);
};
}, []);
const clearCloseTimeout = () => {
if (!closeTimeoutRef.current) return;
clearTimeout(closeTimeoutRef.current);
closeTimeoutRef.current = null;
};
const openOnHover = () => {
clearCloseTimeout();
setOpen((current) => (current ? current : true));
};
const closeAfterHover = () => {
clearCloseTimeout();
closeTimeoutRef.current = setTimeout(() => setOpen(false), HOVER_CLOSE_DELAY_MS);
};
const handleOpenChange = (nextOpen: boolean) => {
clearCloseTimeout();
setOpen(nextOpen);
};
if (links.length === 0) return null;
return (
<DropdownMenu open={open} onOpenChange={handleOpenChange} modal={false}>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon-lg"
className="cursor-pointer text-muted-foreground hover:text-foreground"
aria-label="Integrations"
onPointerEnter={openOnHover}
onPointerLeave={closeAfterHover}
>
<IconPlugConnected className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="w-48"
onPointerEnter={openOnHover}
onPointerLeave={closeAfterHover}
>
<DropdownMenuLabel>Integrations</DropdownMenuLabel>
{links.map((link) => {
const Icon = INTEGRATION_ICONS[link.id];
return (
<DropdownMenuItem key={link.id} asChild className="cursor-pointer">
<Link href={link.href}>
<Icon className="h-4 w-4 text-muted-foreground" />
{link.label}
</Link>
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
);
}
export function MobileIntegrationsSection({
workspaceId,
onNavigate,
}: MobileIntegrationsSectionProps) {
const links = useConfiguredIntegrationLinks(workspaceId);
if (links.length === 0) return null;
return (
<div className="space-y-3">
<div className="text-sm font-medium">Integrations</div>
{links.map((link) => {
const Icon = INTEGRATION_ICONS[link.id];
return (
<Button
key={link.id}
asChild
variant="outline"
className="w-full cursor-pointer justify-start gap-2"
>
<Link href={link.href} onClick={onNavigate}>
<Icon className="h-4 w-4" />
<span className="flex-1 text-left">{link.label}</span>
</Link>
</Button>
);
})}
</div>
);
}
@@ -14,6 +14,11 @@ import { IconAdjustmentsHorizontal } from "@tabler/icons-react";
import { useKanbanDisplaySettings } from "@/hooks/use-kanban-display-settings"; import { useKanbanDisplaySettings } from "@/hooks/use-kanban-display-settings";
import type { Workspace, Repository } from "@/lib/types/http"; import type { Workspace, Repository } from "@/lib/types/http";
import type { WorkflowsState } from "@/lib/state/slices"; import type { WorkflowsState } from "@/lib/state/slices";
import type { ComponentProps } from "react";
type KanbanDisplayDropdownProps = {
triggerSize?: ComponentProps<typeof Button>["size"];
};
function getRepositoryPlaceholder( function getRepositoryPlaceholder(
repositoriesLoading: boolean, repositoriesLoading: boolean,
@@ -128,7 +133,7 @@ function RepositorySection({
); );
} }
export function KanbanDisplayDropdown() { export function KanbanDisplayDropdown({ triggerSize = "icon" }: KanbanDisplayDropdownProps) {
const { const {
workspaces, workspaces,
workflows, workflows,
@@ -152,7 +157,7 @@ export function KanbanDisplayDropdown() {
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button <Button
variant="outline" variant="outline"
size="icon" size={triggerSize}
data-testid="display-button" data-testid="display-button"
className="cursor-pointer" className="cursor-pointer"
> >
@@ -1,17 +1,16 @@
"use client"; "use client";
import Link from "next/link";
import { Button } from "@kandev/ui/button"; import { Button } from "@kandev/ui/button";
import { TooltipProvider } from "@kandev/ui/tooltip";
import { IconMenu2 } from "@tabler/icons-react"; import { IconMenu2 } from "@tabler/icons-react";
import { ReleaseNotesButton } from "../release-notes/release-notes-button"; import { PageTopbar } from "@/components/page-topbar";
import { HealthIndicatorButton } from "../system-health/health-indicator";
import { MobileMenuSheet } from "./mobile-menu-sheet"; import { MobileMenuSheet } from "./mobile-menu-sheet";
import { useAppStore } from "@/components/state-provider"; import { useAppStore } from "@/components/state-provider";
type KanbanHeaderMobileProps = { type KanbanHeaderMobileProps = {
workspaceId?: string; workspaceId?: string;
currentPage?: "kanban" | "tasks"; currentPage?: "kanban" | "tasks";
title: string;
workspaceLabel: string;
searchQuery?: string; searchQuery?: string;
onSearchChange?: (query: string) => void; onSearchChange?: (query: string) => void;
isSearchLoading?: boolean; isSearchLoading?: boolean;
@@ -24,6 +23,8 @@ type KanbanHeaderMobileProps = {
export function KanbanHeaderMobile({ export function KanbanHeaderMobile({
workspaceId, workspaceId,
currentPage = "kanban", currentPage = "kanban",
title,
workspaceLabel,
searchQuery = "", searchQuery = "",
onSearchChange, onSearchChange,
isSearchLoading = false, isSearchLoading = false,
@@ -37,32 +38,23 @@ export function KanbanHeaderMobile({
return ( return (
<> <>
<header className="flex items-center justify-between p-4 pb-3"> <PageTopbar
<Link href="/" className="text-xl font-bold hover:opacity-80"> title={title}
KanDev subtitle={workspaceLabel}
</Link> className="h-10 px-3 py-1"
<div className="flex items-center gap-2"> variant={title === "Home" ? "root" : "breadcrumb"}
{showReleaseNotesButton && ( actions={
<TooltipProvider>
<ReleaseNotesButton hasUnseen onClick={onOpenReleaseNotes} />
</TooltipProvider>
)}
{showHealthIndicator && (
<TooltipProvider>
<HealthIndicatorButton hasIssues={showHealthIndicator} onClick={onOpenHealthDialog} />
</TooltipProvider>
)}
<Button <Button
variant="outline" variant="outline"
size="icon" size="icon-lg"
onClick={() => setMenuOpen(true)} onClick={() => setMenuOpen(true)}
className="cursor-pointer" className="cursor-pointer"
> >
<IconMenu2 className="h-4 w-4" /> <IconMenu2 className="h-4 w-4" />
<span className="sr-only">Open menu</span> <span className="sr-only">Open menu</span>
</Button> </Button>
</div> }
</header> />
<MobileMenuSheet <MobileMenuSheet
open={isMenuOpen} open={isMenuOpen}
onOpenChange={setMenuOpen} onOpenChange={setMenuOpen}
@@ -71,6 +63,10 @@ export function KanbanHeaderMobile({
searchQuery={searchQuery} searchQuery={searchQuery}
onSearchChange={onSearchChange} onSearchChange={onSearchChange}
isSearchLoading={isSearchLoading} isSearchLoading={isSearchLoading}
showReleaseNotesButton={showReleaseNotesButton}
onOpenReleaseNotes={onOpenReleaseNotes}
showHealthIndicator={showHealthIndicator}
onOpenHealthDialog={onOpenHealthDialog}
/> />
</> </>
); );
+291 -207
View File
@@ -4,6 +4,14 @@ import Link from "next/link";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Button } from "@kandev/ui/button"; 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 { ToggleGroup, ToggleGroupItem } from "@kandev/ui/toggle-group";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@kandev/ui/tooltip"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@kandev/ui/tooltip";
import { import {
@@ -14,30 +22,28 @@ import {
IconMenu2, IconMenu2,
IconChartBar, IconChartBar,
IconTimeline, IconTimeline,
IconBrandGithub,
IconTicket,
IconHexagon,
IconStethoscope, IconStethoscope,
IconDots,
IconSparkles,
IconAlertTriangle,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { useJiraAvailable } from "@/components/jira/my-jira/use-jira-availability";
import { useLinearAvailable } from "@/components/linear/use-linear-availability";
import { ImproveKandevDialog } from "@/components/improve-kandev-dialog"; import { ImproveKandevDialog } from "@/components/improve-kandev-dialog";
import { linkToTask } from "@/lib/links"; import { IntegrationsMenu } from "@/components/integrations/integrations-menu";
import { PageTopbar } from "@/components/page-topbar";
import { KanbanDisplayDropdown } from "../kanban-display-dropdown"; import { KanbanDisplayDropdown } from "../kanban-display-dropdown";
import { ReleaseNotesButton } from "../release-notes/release-notes-button";
import { ReleaseNotesDialog } from "../release-notes/release-notes-dialog"; import { ReleaseNotesDialog } from "../release-notes/release-notes-dialog";
import { HealthIndicatorButton, HealthIssuesDialog } from "../system-health/health-indicator"; import { HealthIndicatorButton, HealthIssuesDialog } from "../system-health/health-indicator";
import { TaskSearchInput } from "./task-search-input"; import { TaskSearchInput } from "./task-search-input";
import { QuickChatButton } from "@/components/task/quick-chat-button"; import { QuickChatButton } from "@/components/task/quick-chat-button";
import { KanbanHeaderMobile } from "./kanban-header-mobile"; import { KanbanHeaderMobile } from "./kanban-header-mobile";
import { MobileMenuSheet } from "./mobile-menu-sheet"; import { MobileMenuSheet } from "./mobile-menu-sheet";
import { linkToTasks } from "@/lib/links"; import { linkToTask, linkToTasks } from "@/lib/links";
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint"; import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
import { useAppStore } from "@/components/state-provider"; import { useAppStore } from "@/components/state-provider";
import { useKanbanDisplaySettings } from "@/hooks/use-kanban-display-settings"; import { useKanbanDisplaySettings } from "@/hooks/use-kanban-display-settings";
import { useGitHubStatus } from "@/hooks/domains/github/use-github-status";
import { useReleaseNotes } from "@/hooks/use-release-notes"; import { useReleaseNotes } from "@/hooks/use-release-notes";
import { useSystemHealthIndicator } from "@/hooks/use-system-health-indicator"; import { useSystemHealthIndicator } from "@/hooks/use-system-health-indicator";
import type { ComponentProps, RefObject } from "react";
type KanbanHeaderProps = { type KanbanHeaderProps = {
onCreateTask: () => void; onCreateTask: () => void;
@@ -54,64 +60,99 @@ type ViewToggleItem = {
label: string; label: string;
}; };
type HeaderUtilityMenuProps = {
showReleaseNotesButton: boolean;
onOpenReleaseNotes: () => void;
showHealthIndicator: boolean;
onOpenHealthDialog: () => void;
showStatsLink?: boolean;
buttonSize?: ComponentProps<typeof Button>["size"];
};
const VIEW_TOGGLE_ITEMS: ViewToggleItem[] = [ const VIEW_TOGGLE_ITEMS: ViewToggleItem[] = [
{ value: "kanban", icon: IconLayoutKanban, label: "Kanban" }, { value: "kanban", icon: IconLayoutKanban, label: "Kanban" },
{ value: "pipeline", icon: IconTimeline, label: "Pipeline" }, { value: "pipeline", icon: IconTimeline, label: "Pipeline" },
{ value: "list", icon: IconList, label: "List" }, { value: "list", icon: IconList, label: "List" },
]; ];
function GitHubTopbarButton() { const WORKBENCH_TOPBAR_CLASSNAME = "h-10 px-3 py-1";
const { status } = useGitHubStatus(); const DESKTOP_HEADER_NARROW_PX = 1100;
if (!status?.authenticated) return null;
function getWorkspaceLabel(
workspaces: Array<{ id: string; name: string }>,
activeWorkspaceId: string | null,
): string {
if (!activeWorkspaceId) return "All workspaces";
return workspaces.find((workspace) => workspace.id === activeWorkspaceId)?.name ?? "Workspace";
}
function getHeaderTitle(currentPage: string): string {
return currentPage === "tasks" ? "Tasks" : "Home";
}
function BoardUtilitiesMenu({
showReleaseNotesButton,
onOpenReleaseNotes,
showHealthIndicator,
onOpenHealthDialog,
showStatsLink = true,
buttonSize = "icon",
}: HeaderUtilityMenuProps) {
return ( return (
<Tooltip> <DropdownMenu>
<TooltipTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="outline" size="icon" asChild className="cursor-pointer"> <Button
<Link href="/github"> variant="outline"
<IconBrandGithub className="h-4 w-4" /> size={buttonSize}
</Link> className="cursor-pointer"
aria-label="Utilities"
>
<IconDots className="h-4 w-4" />
</Button> </Button>
</TooltipTrigger> </DropdownMenuTrigger>
<TooltipContent>GitHub</TooltipContent> <DropdownMenuContent align="end" className="w-52">
</Tooltip> <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">
<IconSettings className="h-4 w-4" />
Settings
</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
); );
} }
function JiraTopbarButton({ workspaceId }: { workspaceId: string | undefined }) { function ImproveKandevTopbarButton({
const available = useJiraAvailable(workspaceId); workspaceId,
if (!available) return null; buttonSize = "icon-lg",
return ( }: {
<Tooltip> workspaceId: string | undefined;
<TooltipTrigger asChild> buttonSize?: ComponentProps<typeof Button>["size"];
<Button variant="outline" size="icon" asChild className="cursor-pointer"> }) {
<Link href="/jira">
<IconTicket className="h-4 w-4" />
</Link>
</Button>
</TooltipTrigger>
<TooltipContent>Jira</TooltipContent>
</Tooltip>
);
}
function LinearTopbarButton({ workspaceId }: { workspaceId: string | undefined }) {
const available = useLinearAvailable(workspaceId);
if (!available) return null;
return (
<Tooltip>
<TooltipTrigger asChild>
<Button variant="outline" size="icon" asChild className="cursor-pointer">
<Link href="/linear">
<IconHexagon className="h-4 w-4" />
</Link>
</Button>
</TooltipTrigger>
<TooltipContent>Linear</TooltipContent>
</Tooltip>
);
}
function ImproveKandevTopbarButton({ workspaceId }: { workspaceId: string | undefined }) {
const router = useRouter(); const router = useRouter();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
return ( return (
@@ -120,7 +161,7 @@ function ImproveKandevTopbarButton({ workspaceId }: { workspaceId: string | unde
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <Button
variant="outline" variant="outline"
size="icon" size={buttonSize}
onClick={() => setOpen(true)} onClick={() => setOpen(true)}
className="cursor-pointer" className="cursor-pointer"
data-testid="improve-kandev-button" data-testid="improve-kandev-button"
@@ -140,14 +181,40 @@ function ImproveKandevTopbarButton({ workspaceId }: { workspaceId: string | unde
); );
} }
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>
<IntegrationsMenu workspaceId={workspaceId} />
<ImproveKandevTopbarButton workspaceId={workspaceId} />
</>
);
}
function WorkspaceLeftActions({ workspaceId }: { workspaceId?: string }) {
return (
<>
<IntegrationsMenu workspaceId={workspaceId} />
<ImproveKandevTopbarButton workspaceId={workspaceId} />
</>
);
}
function ViewToggleGroup({ function ViewToggleGroup({
toggleValue, toggleValue,
onValueChange, onValueChange,
size,
className, className,
itemClassName, itemClassName,
}: { }: {
toggleValue: string; toggleValue: string;
onValueChange: (value: string) => void; onValueChange: (value: string) => void;
size?: ComponentProps<typeof ToggleGroup>["size"];
className?: string; className?: string;
itemClassName?: string; itemClassName?: string;
}) { }) {
@@ -157,6 +224,7 @@ function ViewToggleGroup({
value={toggleValue} value={toggleValue}
onValueChange={onValueChange} onValueChange={onValueChange}
variant="outline" variant="outline"
size={size}
className={className} className={className}
> >
{VIEW_TOGGLE_ITEMS.map(({ value, icon: Icon, label }) => ( {VIEW_TOGGLE_ITEMS.map(({ value, icon: Icon, label }) => (
@@ -185,97 +253,9 @@ function getToggleValue(currentPage: string, kanbanViewMode: string | null): str
return "kanban"; return "kanban";
} }
function TabletHeader({ function useIsHeaderNarrow(ref: RefObject<HTMLElement | null>): boolean {
onCreateTask,
workspaceId,
searchQuery,
onSearchChange,
isSearchLoading,
toggleValue,
handleViewChange,
setMenuOpen,
showReleaseNotesButton,
onOpenReleaseNotes,
showHealthIndicator,
onOpenHealthDialog,
}: {
onCreateTask: () => void;
workspaceId?: string;
searchQuery: string;
onSearchChange?: (query: string) => void;
isSearchLoading: boolean;
toggleValue: string;
handleViewChange: (value: string) => void;
setMenuOpen: (open: boolean) => void;
showReleaseNotesButton: boolean;
onOpenReleaseNotes: () => void;
showHealthIndicator: boolean;
onOpenHealthDialog: () => void;
}) {
return (
<header className="flex items-center justify-between p-4 pb-3 gap-3">
<div className="flex items-center gap-3 flex-shrink-0">
<Link href="/" className="text-xl font-bold hover:opacity-80">
KanDev
</Link>
<TooltipProvider>
<GitHubTopbarButton />
<JiraTopbarButton workspaceId={workspaceId} />
<LinearTopbarButton workspaceId={workspaceId} />
<ImproveKandevTopbarButton workspaceId={workspaceId} />
</TooltipProvider>
</div>
{onSearchChange && (
<TaskSearchInput
value={searchQuery}
onChange={onSearchChange}
placeholder="Search..."
isLoading={isSearchLoading}
className="flex-1 max-w-[200px]"
/>
)}
<div className="flex items-center gap-2">
<Button
onClick={onCreateTask}
size="lg"
className="cursor-pointer"
data-testid="create-task-button"
>
<IconPlus className="h-4 w-4" />
<span className="hidden sm:inline ml-1">Add task</span>
</Button>
<QuickChatButton workspaceId={workspaceId} />
<TooltipProvider>
<ViewToggleGroup
toggleValue={toggleValue}
onValueChange={handleViewChange}
className="h-8"
itemClassName="h-8 w-8"
/>
{showReleaseNotesButton && <ReleaseNotesButton hasUnseen onClick={onOpenReleaseNotes} />}
<HealthIndicatorButton hasIssues={showHealthIndicator} onClick={onOpenHealthDialog} />
</TooltipProvider>
<Button
variant="outline"
size="icon-lg"
onClick={() => setMenuOpen(true)}
className="cursor-pointer"
>
<IconMenu2 className="h-4 w-4" />
<span className="sr-only">Open menu</span>
</Button>
</div>
</header>
);
}
// Width below which the centered search input no longer fits between the
// left ("KanDev" + GitHub/Jira/Stats) and right (Add task + chat + view
// toggle + indicators + display + settings) action groups.
const DESKTOP_HEADER_NARROW_PX = 1100;
function useIsHeaderNarrow(ref: React.RefObject<HTMLElement | null>): boolean {
const [isNarrow, setIsNarrow] = useState(false); const [isNarrow, setIsNarrow] = useState(false);
useEffect(() => { useEffect(() => {
const el = ref.current; const el = ref.current;
if (!el) return; if (!el) return;
@@ -285,12 +265,103 @@ function useIsHeaderNarrow(ref: React.RefObject<HTMLElement | null>): boolean {
observer.observe(el); observer.observe(el);
return () => observer.disconnect(); return () => observer.disconnect();
}, [ref]); }, [ref]);
return isNarrow; return isNarrow;
} }
function TabletHeader({
onCreateTask,
workspaceId,
title,
workspaceLabel,
searchQuery,
onSearchChange,
isSearchLoading,
toggleValue,
handleViewChange,
setMenuOpen,
showHealthIndicator,
onOpenHealthDialog,
}: {
onCreateTask: () => void;
workspaceId?: string;
title: string;
workspaceLabel: string;
searchQuery: string;
onSearchChange?: (query: string) => void;
isSearchLoading: boolean;
toggleValue: string;
handleViewChange: (value: string) => void;
setMenuOpen: (open: boolean) => void;
showHealthIndicator: boolean;
onOpenHealthDialog: () => void;
}) {
const isHome = title === "Home";
return (
<PageTopbar
title={title}
subtitle={workspaceLabel}
className={WORKBENCH_TOPBAR_CLASSNAME}
variant={isHome ? "root" : "breadcrumb"}
leftActions={
isHome ? (
<HomeLeftActions workspaceId={workspaceId} />
) : (
<WorkspaceLeftActions workspaceId={workspaceId} />
)
}
actionsClassName="gap-2"
actions={
<>
{onSearchChange && (
<TaskSearchInput
value={searchQuery}
onChange={onSearchChange}
placeholder="Search..."
isLoading={isSearchLoading}
className="hidden md:flex w-48 lg:w-56 [&_input]:h-8"
/>
)}
<Button
onClick={onCreateTask}
size="lg"
className="cursor-pointer"
data-testid="create-task-button"
>
<IconPlus className="h-4 w-4" />
<span className="hidden sm:inline ml-1">Add task</span>
</Button>
<QuickChatButton workspaceId={workspaceId} size="lg" />
<TooltipProvider>
<ViewToggleGroup toggleValue={toggleValue} onValueChange={handleViewChange} size="lg" />
</TooltipProvider>
<KanbanDisplayDropdown triggerSize="icon-lg" />
<HealthIndicatorButton
hasIssues={showHealthIndicator}
onClick={onOpenHealthDialog}
size="icon-lg"
/>
<Button
variant="outline"
size="icon-lg"
onClick={() => setMenuOpen(true)}
className="cursor-pointer"
>
<IconMenu2 className="h-4 w-4" />
<span className="sr-only">Open menu</span>
</Button>
</>
}
/>
);
}
function DesktopHeader({ function DesktopHeader({
onCreateTask, onCreateTask,
workspaceId, workspaceId,
title,
workspaceLabel,
searchQuery, searchQuery,
onSearchChange, onSearchChange,
isSearchLoading, isSearchLoading,
@@ -303,6 +374,8 @@ function DesktopHeader({
}: { }: {
onCreateTask: () => void; onCreateTask: () => void;
workspaceId?: string; workspaceId?: string;
title: string;
workspaceLabel: string;
searchQuery: string; searchQuery: string;
onSearchChange?: (query: string) => void; onSearchChange?: (query: string) => void;
isSearchLoading: boolean; isSearchLoading: boolean;
@@ -313,69 +386,70 @@ function DesktopHeader({
showHealthIndicator: boolean; showHealthIndicator: boolean;
onOpenHealthDialog: () => void; onOpenHealthDialog: () => void;
}) { }) {
// The search input is absolutely centered, so the left/right action groups
// can grow into it and overlap. Hide the search when the header gets too
// narrow to fit all three regions side-by-side (e.g. when the kanban preview
// panel is open and squeezes the board area).
const headerRef = useRef<HTMLElement>(null); const headerRef = useRef<HTMLElement>(null);
const isNarrow = useIsHeaderNarrow(headerRef); const isNarrow = useIsHeaderNarrow(headerRef);
const showSearch = !!onSearchChange && !isNarrow; const searchInput = onSearchChange ? (
<TaskSearchInput
value={searchQuery}
onChange={onSearchChange}
placeholder="Search tasks..."
isLoading={isSearchLoading}
className="w-72 xl:w-80 [&_input]:h-8"
/>
) : null;
const isHome = title === "Home";
const centerSearch =
isHome && searchInput && !isNarrow ? (
<div data-testid="kanban-header-search">{searchInput}</div>
) : null;
const leftActions = isHome ? (
<HomeLeftActions workspaceId={workspaceId} />
) : (
<WorkspaceLeftActions workspaceId={workspaceId} />
);
return ( return (
<header ref={headerRef} className="relative flex items-center justify-between p-4 pb-3"> <PageTopbar
<div className="flex items-center gap-5"> ref={headerRef}
<Link href="/" className="text-2xl font-bold hover:opacity-80"> title={title}
KanDev subtitle={workspaceLabel}
</Link> center={centerSearch}
<div className="flex items-center gap-3"> className={WORKBENCH_TOPBAR_CLASSNAME}
<TooltipProvider> variant={isHome ? "root" : "breadcrumb"}
<GitHubTopbarButton /> leftActions={leftActions}
<JiraTopbarButton workspaceId={workspaceId} /> actions={
<LinearTopbarButton workspaceId={workspaceId} /> <>
<Tooltip> {!isHome && searchInput}
<TooltipTrigger asChild> <Button
<Button variant="outline" size="icon" asChild className="cursor-pointer"> onClick={onCreateTask}
<Link href="/stats"> size="lg"
<IconChartBar className="h-4 w-4" /> className="cursor-pointer"
</Link> data-testid="create-task-button"
</Button> >
</TooltipTrigger> <IconPlus className="h-4 w-4" />
<TooltipContent>Stats</TooltipContent> Add task
</Tooltip>
<ImproveKandevTopbarButton workspaceId={workspaceId} />
</TooltipProvider>
</div>
</div>
{showSearch && (
<div className="absolute left-1/2 -translate-x-1/2" data-testid="kanban-header-search">
<TaskSearchInput
value={searchQuery}
onChange={onSearchChange}
placeholder="Search tasks..."
isLoading={isSearchLoading}
className="w-64"
/>
</div>
)}
<div className="flex items-center gap-3">
<Button onClick={onCreateTask} className="cursor-pointer" data-testid="create-task-button">
<IconPlus className="h-4 w-4" />
Add task
</Button>
<QuickChatButton workspaceId={workspaceId} />
<TooltipProvider>
<ViewToggleGroup toggleValue={toggleValue} onValueChange={handleViewChange} />
</TooltipProvider>
{showReleaseNotesButton && <ReleaseNotesButton hasUnseen onClick={onOpenReleaseNotes} />}
<HealthIndicatorButton hasIssues={showHealthIndicator} onClick={onOpenHealthDialog} />
<KanbanDisplayDropdown />
<Link href="/settings" className="cursor-pointer">
<Button variant="outline" className="cursor-pointer gap-2">
<IconSettings className="h-4 w-4" />
<span className="hidden 2xl:inline">Settings</span>
</Button> </Button>
</Link> <QuickChatButton workspaceId={workspaceId} size="lg" />
</div> <TooltipProvider>
</header> <ViewToggleGroup toggleValue={toggleValue} onValueChange={handleViewChange} size="lg" />
</TooltipProvider>
<KanbanDisplayDropdown triggerSize="icon-lg" />
<HealthIndicatorButton
hasIssues={showHealthIndicator}
onClick={onOpenHealthDialog}
size="icon-lg"
/>
<BoardUtilitiesMenu
showReleaseNotesButton={showReleaseNotesButton}
onOpenReleaseNotes={onOpenReleaseNotes}
showHealthIndicator={showHealthIndicator}
onOpenHealthDialog={onOpenHealthDialog}
showStatsLink={!isHome}
buttonSize="icon-lg"
/>
</>
}
/>
); );
} }
@@ -409,11 +483,14 @@ export function KanbanHeader({
const { isMobile, isTablet } = useResponsiveBreakpoint(); const { isMobile, isTablet } = useResponsiveBreakpoint();
const isMenuOpen = useAppStore((state) => state.mobileKanban.isMenuOpen); const isMenuOpen = useAppStore((state) => state.mobileKanban.isMenuOpen);
const setMenuOpen = useAppStore((state) => state.setMobileKanbanMenuOpen); const setMenuOpen = useAppStore((state) => state.setMobileKanbanMenuOpen);
const { kanbanViewMode, onViewModeChange } = useKanbanDisplaySettings(); const { kanbanViewMode, onViewModeChange, workspaces, activeWorkspaceId } =
useKanbanDisplaySettings();
const releaseNotes = useReleaseNotes(); const releaseNotes = useReleaseNotes();
const healthIndicator = useSystemHealthIndicator(); const healthIndicator = useSystemHealthIndicator();
const toggleValue = getToggleValue(currentPage, kanbanViewMode); const toggleValue = getToggleValue(currentPage, kanbanViewMode);
const handleViewChange = useHeaderViewChange(currentPage, workspaceId, onViewModeChange); const handleViewChange = useHeaderViewChange(currentPage, workspaceId, onViewModeChange);
const title = getHeaderTitle(currentPage);
const workspaceLabel = getWorkspaceLabel(workspaces, activeWorkspaceId);
const indicatorProps = { const indicatorProps = {
showReleaseNotesButton: releaseNotes.showTopbarButton, showReleaseNotesButton: releaseNotes.showTopbarButton,
@@ -430,6 +507,8 @@ export function KanbanHeader({
<KanbanHeaderMobile <KanbanHeaderMobile
workspaceId={workspaceId} workspaceId={workspaceId}
currentPage={currentPage} currentPage={currentPage}
title={title}
workspaceLabel={workspaceLabel}
{...sharedSearch} {...sharedSearch}
{...indicatorProps} {...indicatorProps}
/> />
@@ -440,6 +519,8 @@ export function KanbanHeader({
<> <>
<TabletHeader <TabletHeader
{...sharedActions} {...sharedActions}
title={title}
workspaceLabel={workspaceLabel}
{...sharedSearch} {...sharedSearch}
toggleValue={toggleValue} toggleValue={toggleValue}
handleViewChange={handleViewChange} handleViewChange={handleViewChange}
@@ -452,6 +533,7 @@ export function KanbanHeader({
workspaceId={workspaceId} workspaceId={workspaceId}
currentPage={currentPage} currentPage={currentPage}
{...sharedSearch} {...sharedSearch}
{...indicatorProps}
/> />
</> </>
); );
@@ -459,6 +541,8 @@ export function KanbanHeader({
return ( return (
<DesktopHeader <DesktopHeader
{...sharedActions} {...sharedActions}
title={title}
workspaceLabel={workspaceLabel}
{...sharedSearch} {...sharedSearch}
toggleValue={toggleValue} toggleValue={toggleValue}
handleViewChange={handleViewChange} handleViewChange={handleViewChange}
+177 -53
View File
@@ -7,7 +7,16 @@ import { Button } from "@kandev/ui/button";
import { Checkbox } from "@kandev/ui/checkbox"; import { Checkbox } from "@kandev/ui/checkbox";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select";
import { ToggleGroup, ToggleGroupItem } from "@kandev/ui/toggle-group"; import { ToggleGroup, ToggleGroupItem } from "@kandev/ui/toggle-group";
import { IconSettings, IconList, IconLayoutKanban, IconChartBar } from "@tabler/icons-react"; import {
IconAlertTriangle,
IconChartBar,
IconLayoutKanban,
IconList,
IconSettings,
IconSparkles,
IconTimeline,
} from "@tabler/icons-react";
import { MobileIntegrationsSection } from "@/components/integrations/integrations-menu";
import { TaskSearchInput } from "./task-search-input"; import { TaskSearchInput } from "./task-search-input";
import { useKanbanDisplaySettings } from "@/hooks/use-kanban-display-settings"; import { useKanbanDisplaySettings } from "@/hooks/use-kanban-display-settings";
import { linkToTasks } from "@/lib/links"; import { linkToTasks } from "@/lib/links";
@@ -22,6 +31,10 @@ type MobileMenuSheetProps = {
searchQuery?: string; searchQuery?: string;
onSearchChange?: (query: string) => void; onSearchChange?: (query: string) => void;
isSearchLoading?: boolean; isSearchLoading?: boolean;
showReleaseNotesButton: boolean;
onOpenReleaseNotes: () => void;
showHealthIndicator: boolean;
onOpenHealthDialog: () => void;
}; };
function getRepositoryPlaceholder(loading: boolean, empty: boolean): string { function getRepositoryPlaceholder(loading: boolean, empty: boolean): string {
@@ -30,6 +43,12 @@ function getRepositoryPlaceholder(loading: boolean, empty: boolean): string {
return "Select repository"; return "Select repository";
} }
function getMobileViewValue(currentPage: string, kanbanViewMode: string | null): string {
if (currentPage === "tasks") return "list";
if (kanbanViewMode === "graph2") return "pipeline";
return "kanban";
}
type MobileDisplayOptionsProps = { type MobileDisplayOptionsProps = {
activeWorkspaceId: string | null; activeWorkspaceId: string | null;
workspaces: Workspace[]; workspaces: Workspace[];
@@ -145,21 +164,134 @@ function MobileDisplayOptions(props: MobileDisplayOptionsProps) {
); );
} }
function MobileNavLinks({ onOpenChange }: { onOpenChange: (open: boolean) => void }) { function MobileSearchSection({
searchQuery,
onSearchChange,
isSearchLoading,
}: {
searchQuery: string;
onSearchChange?: (query: string) => void;
isSearchLoading: boolean;
}) {
if (!onSearchChange) return null;
return (
<div className="space-y-2">
<label className="text-sm font-medium">Search</label>
<TaskSearchInput
value={searchQuery}
onChange={onSearchChange}
placeholder="Search tasks..."
isLoading={isSearchLoading}
className="w-full"
/>
</div>
);
}
function MobileViewSection({
viewValue,
onViewChange,
}: {
viewValue: string;
onViewChange: (value: string) => void;
}) {
return (
<div className="space-y-2">
<label className="text-sm font-medium">View</label>
<ToggleGroup
type="single"
value={viewValue}
onValueChange={onViewChange}
variant="outline"
className="w-full justify-start"
>
<ToggleGroupItem
value="kanban"
className="cursor-pointer flex-1 data-[state=on]:bg-muted data-[state=on]:text-foreground"
>
<IconLayoutKanban className="h-4 w-4 mr-2" />
Kanban
</ToggleGroupItem>
<ToggleGroupItem
value="pipeline"
className="cursor-pointer flex-1 data-[state=on]:bg-muted data-[state=on]:text-foreground"
>
<IconTimeline className="h-4 w-4 mr-2" />
Pipeline
</ToggleGroupItem>
<ToggleGroupItem
value="list"
className="cursor-pointer flex-1 data-[state=on]:bg-muted data-[state=on]:text-foreground"
>
<IconList className="h-4 w-4 mr-2" />
List
</ToggleGroupItem>
</ToggleGroup>
</div>
);
}
function MobileUtilityActions({
showReleaseNotesButton,
onOpenReleaseNotes,
showHealthIndicator,
onOpenHealthDialog,
onOpenChange,
}: {
showReleaseNotesButton: boolean;
onOpenReleaseNotes: () => void;
showHealthIndicator: boolean;
onOpenHealthDialog: () => void;
onOpenChange: (open: boolean) => void;
}) {
const closeSheet = () => onOpenChange(false);
const openReleaseNotes = () => {
closeSheet();
onOpenReleaseNotes();
};
const openHealth = () => {
closeSheet();
onOpenHealthDialog();
};
return ( return (
<div className="mt-auto flex flex-col gap-3 pt-4 border-t border-border"> <div className="mt-auto flex flex-col gap-3 pt-4 border-t border-border">
<Link href="/stats" onClick={() => onOpenChange(false)}> <div className="text-sm font-medium">Utilities</div>
<Button variant="outline" className="w-full cursor-pointer"> {showReleaseNotesButton && (
<Button
type="button"
variant="outline"
className="w-full cursor-pointer justify-start gap-2"
onClick={openReleaseNotes}
>
<IconSparkles className="h-4 w-4" />
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>
<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" /> <IconChartBar className="h-4 w-4 mr-2" />
Stats Stats
</Button> </Link>
</Link> </Button>
<Link href="/settings" onClick={() => onOpenChange(false)}> <Button asChild variant="outline" className="w-full cursor-pointer justify-start gap-2">
<Button variant="outline" className="w-full cursor-pointer"> <Link href="/settings" onClick={closeSheet}>
<IconSettings className="h-4 w-4 mr-2" /> <IconSettings className="h-4 w-4 mr-2" />
Settings Settings
</Button> </Link>
</Link> </Button>
</div> </div>
); );
} }
@@ -172,6 +304,10 @@ export function MobileMenuSheet({
searchQuery = "", searchQuery = "",
onSearchChange, onSearchChange,
isSearchLoading = false, isSearchLoading = false,
showReleaseNotesButton,
onOpenReleaseNotes,
showHealthIndicator,
onOpenHealthDialog,
}: MobileMenuSheetProps) { }: MobileMenuSheetProps) {
const router = useRouter(); const router = useRouter();
const { const {
@@ -188,16 +324,25 @@ export function MobileMenuSheet({
onWorkflowChange, onWorkflowChange,
onRepositoryChange, onRepositoryChange,
onTogglePreviewOnClick, onTogglePreviewOnClick,
kanbanViewMode,
onViewModeChange,
} = useKanbanDisplaySettings(); } = useKanbanDisplaySettings();
const repositoryValue = allRepositoriesSelected ? "all" : (selectedRepositoryId ?? "all"); const repositoryValue = allRepositoriesSelected ? "all" : (selectedRepositoryId ?? "all");
const viewValue = getMobileViewValue(currentPage, kanbanViewMode);
const handleViewChange = (value: string) => { const handleViewChange = (value: string) => {
if (value === "list" && currentPage !== "tasks") { if (!value) return;
router.push(linkToTasks(workspaceId)); if (value === "list") {
if (currentPage !== "tasks") router.push(linkToTasks(workspaceId));
onOpenChange(false); onOpenChange(false);
} else if (value === "kanban" && currentPage !== "kanban") { } else if (value === "kanban") {
router.push("/"); if (currentPage !== "kanban") router.push("/");
onViewModeChange("");
onOpenChange(false);
} else if (value === "pipeline") {
if (currentPage !== "kanban") router.push("/");
onViewModeChange("graph2");
onOpenChange(false); onOpenChange(false);
} }
}; };
@@ -209,44 +354,12 @@ export function MobileMenuSheet({
<SheetTitle>Menu</SheetTitle> <SheetTitle>Menu</SheetTitle>
</SheetHeader> </SheetHeader>
<div className="flex flex-col gap-6 p-4"> <div className="flex flex-col gap-6 p-4">
{onSearchChange && ( <MobileSearchSection
<div className="space-y-2"> searchQuery={searchQuery}
<label className="text-sm font-medium">Search</label> onSearchChange={onSearchChange}
<TaskSearchInput isSearchLoading={isSearchLoading}
value={searchQuery} />
onChange={onSearchChange} <MobileViewSection viewValue={viewValue} onViewChange={handleViewChange} />
placeholder="Search tasks..."
isLoading={isSearchLoading}
className="w-full"
/>
</div>
)}
<div className="space-y-2">
<label className="text-sm font-medium">View</label>
<ToggleGroup
type="single"
value={currentPage === "tasks" ? "list" : "kanban"}
onValueChange={handleViewChange}
variant="outline"
className="w-full justify-start"
>
<ToggleGroupItem
value="kanban"
className="cursor-pointer flex-1 data-[state=on]:bg-muted data-[state=on]:text-foreground"
>
<IconLayoutKanban className="h-4 w-4 mr-2" />
Kanban
</ToggleGroupItem>
<ToggleGroupItem
value="list"
className="cursor-pointer flex-1 data-[state=on]:bg-muted data-[state=on]:text-foreground"
>
<IconList className="h-4 w-4 mr-2" />
List
</ToggleGroupItem>
</ToggleGroup>
</div>
<MobileDisplayOptions <MobileDisplayOptions
activeWorkspaceId={activeWorkspaceId} activeWorkspaceId={activeWorkspaceId}
@@ -263,7 +376,18 @@ export function MobileMenuSheet({
onTogglePreviewOnClick={onTogglePreviewOnClick} onTogglePreviewOnClick={onTogglePreviewOnClick}
/> />
<MobileNavLinks onOpenChange={onOpenChange} /> <MobileIntegrationsSection
workspaceId={workspaceId}
onNavigate={() => onOpenChange(false)}
/>
<MobileUtilityActions
showReleaseNotesButton={showReleaseNotesButton}
onOpenReleaseNotes={onOpenReleaseNotes}
showHealthIndicator={showHealthIndicator}
onOpenHealthDialog={onOpenHealthDialog}
onOpenChange={onOpenChange}
/>
</div> </div>
</SheetContent> </SheetContent>
</Sheet> </Sheet>
+104 -24
View File
@@ -1,5 +1,16 @@
import Link from "next/link"; import Link from "next/link";
import { forwardRef } from "react";
import type { ReactNode } from "react";
import { IconArrowLeft } from "@tabler/icons-react"; import { IconArrowLeft } from "@tabler/icons-react";
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@kandev/ui/breadcrumb";
import { cn } from "@kandev/ui/lib/utils";
type PageTopbarProps = { type PageTopbarProps = {
/** Page title shown in the breadcrumb */ /** Page title shown in the breadcrumb */
@@ -7,35 +18,104 @@ type PageTopbarProps = {
/** Optional subtitle shown to the right of the title */ /** Optional subtitle shown to the right of the title */
subtitle?: string; subtitle?: string;
/** Optional icon rendered before the title */ /** Optional icon rendered before the title */
icon?: React.ReactNode; icon?: ReactNode;
/** Where the back link navigates to (default: "/") */ /** Where the back link navigates to (default: "/") */
backHref?: string; backHref?: string;
/** Label for the parent breadcrumb (default: "KanDev") */
backLabel?: string;
/** Optional content rendered before the breadcrumb */
leading?: ReactNode;
/** Optional content rendered at the visual center of the topbar */
center?: ReactNode;
/** Optional content rendered alongside the left orientation label */
leftActions?: ReactNode;
/** Optional content rendered on the right side of the topbar */ /** Optional content rendered on the right side of the topbar */
actions?: React.ReactNode; actions?: ReactNode;
variant?: "breadcrumb" | "root";
className?: string;
centerClassName?: string;
actionsClassName?: string;
}; };
export function PageTopbar({ title, subtitle, icon, backHref = "/", actions }: PageTopbarProps) { export const PageTopbar = forwardRef<HTMLElement, PageTopbarProps>(function PageTopbar(
{
title,
subtitle,
icon,
backHref = "/",
backLabel = "KanDev",
leading,
center,
leftActions,
actions,
variant = "breadcrumb",
className,
centerClassName,
actionsClassName,
},
ref,
) {
return ( return (
<header className="flex items-center gap-3 px-4 py-3 border-b shrink-0"> <header
<Link ref={ref}
href={backHref} className={cn("relative flex h-14 shrink-0 items-center gap-3 border-b px-4", className)}
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground cursor-pointer transition-colors" >
> {leading}
<IconArrowLeft className="h-3.5 w-3.5" /> {variant === "root" ? (
KanDev <div className="relative z-10 flex min-w-0 items-center">
</Link> <span className="truncate text-[15px] font-semibold leading-none">{backLabel}</span>
<span className="text-muted-foreground/50">›</span> </div>
<div className="flex items-center gap-2"> ) : (
{icon} <Breadcrumb className="relative z-10 min-w-0">
<span className="text-sm font-semibold">{title}</span> <BreadcrumbList className="flex-nowrap">
{subtitle && ( <BreadcrumbItem className="shrink-0">
<> <BreadcrumbLink asChild className="flex items-center gap-1.5 cursor-pointer">
<span className="text-muted-foreground/50">·</span> <Link href={backHref}>
<span className="text-xs text-muted-foreground">{subtitle}</span> <IconArrowLeft className="h-3.5 w-3.5" />
</> {backLabel}
)} </Link>
</div> </BreadcrumbLink>
{actions && <div className="ml-auto flex items-center gap-2">{actions}</div>} </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>
{subtitle && (
<>
<span className="hidden text-muted-foreground/50 sm:inline">·</span>
<span className="hidden truncate text-xs text-muted-foreground sm:inline">
{subtitle}
</span>
</>
)}
</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
)}
{leftActions && (
<div className="relative z-10 flex shrink-0 items-center gap-1 [&:empty]:hidden">
{leftActions}
</div>
)}
{center && (
<div
className={cn(
"pointer-events-none absolute left-1/2 top-1/2 z-0 -translate-x-1/2 -translate-y-1/2",
centerClassName,
)}
>
<div className="pointer-events-auto">{center}</div>
</div>
)}
{actions && (
<div
className={cn("relative z-10 ml-auto flex shrink-0 items-center gap-2", actionsClassName)}
>
{actions}
</div>
)}
</header> </header>
); );
} });
@@ -1,38 +1,30 @@
"use client"; "use client";
import { IconArrowLeft } from "@tabler/icons-react";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList } from "@kandev/ui/breadcrumb";
import { SidebarInset, SidebarProvider, SidebarTrigger } from "@kandev/ui/sidebar"; import { SidebarInset, SidebarProvider, SidebarTrigger } from "@kandev/ui/sidebar";
import { TooltipProvider } from "@kandev/ui/tooltip"; import { TooltipProvider } from "@kandev/ui/tooltip";
import { PageTopbar } from "@/components/page-topbar";
import { SettingsAppSidebar } from "@/components/settings/settings-app-sidebar"; import { SettingsAppSidebar } from "@/components/settings/settings-app-sidebar";
export function SettingsLayoutClient({ children }: { children: React.ReactNode }) { export function SettingsLayoutClient({ children }: { children: React.ReactNode }) {
const pathname = usePathname(); const pathname = usePathname();
const isAgentDetail = pathname.startsWith("/settings/agents/") && pathname !== "/settings/agents"; const isAgentDetail = pathname.startsWith("/settings/agents/") && pathname !== "/settings/agents";
const breadcrumbLabel = isAgentDetail ? "Agents" : "Home"; const backHref = isAgentDetail ? "/settings/agents" : "/";
const breadcrumbHref = isAgentDetail ? "/settings/agents" : "/"; const backLabel = isAgentDetail ? "Agents" : "KanDev";
const title = isAgentDetail ? "Agent" : "Settings";
return ( return (
<TooltipProvider> <TooltipProvider>
<SidebarProvider> <SidebarProvider>
<SettingsAppSidebar /> <SettingsAppSidebar />
<SidebarInset> <SidebarInset>
<header className="flex h-16 shrink-0 items-center gap-2"> <PageTopbar
<div className="flex items-center gap-2 px-0 sm:px-4"> title={title}
<SidebarTrigger size="lg" className="md:hidden h-10 w-10 cursor-pointer" /> backHref={backHref}
<Breadcrumb> backLabel={backLabel}
<BreadcrumbList> className="h-16"
<BreadcrumbItem> leading={<SidebarTrigger size="lg" className="md:hidden h-10 w-10 cursor-pointer" />}
<BreadcrumbLink href={breadcrumbHref} className="flex items-center gap-2"> />
<IconArrowLeft className="h-4 w-4" />
{breadcrumbLabel}
</BreadcrumbLink>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
</div>
</header>
<div className="flex min-w-0 flex-1 flex-col gap-4 p-4 pt-0 mb-20">{children}</div> <div className="flex min-w-0 flex-1 flex-col gap-4 p-4 pt-0 mb-20">{children}</div>
</SidebarInset> </SidebarInset>
</SidebarProvider> </SidebarProvider>
@@ -1,6 +1,7 @@
"use client"; "use client";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import type { ComponentProps } from "react";
import { Button } from "@kandev/ui/button"; import { Button } from "@kandev/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { import {
@@ -17,15 +18,20 @@ import type { HealthIssue } from "@/lib/types/health";
type HealthIndicatorButtonProps = { type HealthIndicatorButtonProps = {
hasIssues: boolean; hasIssues: boolean;
onClick: () => void; onClick: () => void;
size?: ComponentProps<typeof Button>["size"];
}; };
export function HealthIndicatorButton({ hasIssues, onClick }: HealthIndicatorButtonProps) { export function HealthIndicatorButton({
hasIssues,
onClick,
size = "icon",
}: HealthIndicatorButtonProps) {
if (!hasIssues) return null; if (!hasIssues) return null;
return ( return (
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button variant="outline" size="icon" onClick={onClick} className="cursor-pointer relative"> <Button variant="outline" size={size} onClick={onClick} className="cursor-pointer relative">
<IconAlertTriangle className="h-4 w-4 text-amber-500" /> <IconAlertTriangle className="h-4 w-4 text-amber-500" />
<span className="absolute -top-1 -right-1 h-2.5 w-2.5 rounded-full bg-amber-500 border-2 border-background" /> <span className="absolute -top-1 -right-1 h-2.5 w-2.5 rounded-full bg-amber-500 border-2 border-background" />
<span className="sr-only">Setup Issues</span> <span className="sr-only">Setup Issues</span>
@@ -0,0 +1,48 @@
import type { DialogComputedArgs, StepType } from "@/components/task-create-dialog-types";
import { computeEffectiveStepId } from "@/components/task-create-dialog-helpers";
export function computeSingleWorkflowFallbackId(
selectedWorkflowId: string | null,
workflowId: string | null,
workflows: DialogComputedArgs["workflows"],
): string | null {
if (selectedWorkflowId || workflowId || workflows.length !== 1) return null;
return workflows[0]?.id ?? null;
}
export function computeSnapshotDefaultStepId(
workflowId: string | null,
snapshots: DialogComputedArgs["snapshots"],
): string | null {
if (!workflowId) return null;
const steps = snapshots[workflowId]?.steps ?? [];
const startStep = steps.find((step) => step.is_start_step);
if (startStep) return startStep.id;
return [...steps].sort((a, b) => a.position - b.position)[0]?.id ?? null;
}
type ComputeDialogDefaultStepIdArgs = {
selectedWorkflowId: string | null;
workflowId: string | null;
fetchedSteps: StepType[] | null;
defaultStepId: string | null;
effectiveWorkflowId: string | null;
snapshots: DialogComputedArgs["snapshots"];
};
export function computeDialogDefaultStepId({
selectedWorkflowId,
workflowId,
fetchedSteps,
defaultStepId,
effectiveWorkflowId,
snapshots,
}: ComputeDialogDefaultStepIdArgs): string | null {
const switchedWorkflowWithoutFetchedSteps =
Boolean(selectedWorkflowId) && selectedWorkflowId !== workflowId && !fetchedSteps;
const computedStepId = switchedWorkflowWithoutFetchedSteps
? null
: computeEffectiveStepId(selectedWorkflowId, workflowId, fetchedSteps, defaultStepId);
return computedStepId ?? computeSnapshotDefaultStepId(effectiveWorkflowId, snapshots);
}
@@ -67,11 +67,11 @@ function StartTaskSplitButton({
return ( return (
<div className="flex flex-col w-full sm:w-auto gap-2 sm:gap-0"> <div className="flex flex-col w-full sm:w-auto gap-2 sm:gap-0">
<div className="flex w-full sm:inline-flex sm:w-auto rounded-md border border-border overflow-hidden sm:h-7 h-10"> <div className="flex w-full sm:inline-flex sm:w-auto sm:h-7 h-10">
<Button <Button
type="submit" type="submit"
variant="default" variant="default"
className="rounded-none border-0 cursor-pointer gap-1.5 h-full flex-1" className="h-full flex-1 cursor-pointer gap-1.5 sm:rounded-r-none sm:border-r-0"
disabled={disabled} disabled={disabled}
data-testid="submit-start-agent" data-testid="submit-start-agent"
> >
@@ -87,7 +87,7 @@ function StartTaskSplitButton({
<Button <Button
type="button" type="button"
variant="default" variant="default"
className="rounded-none border-0 border-l border-primary-foreground/20 px-2 cursor-pointer h-full hidden sm:flex" className="-ml-px h-full hidden rounded-l-none border-l border-primary-foreground/20 px-2 cursor-pointer sm:flex"
disabled={disabled} disabled={disabled}
data-testid="submit-start-agent-chevron" data-testid="submit-start-agent-chevron"
> >
@@ -0,0 +1,39 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { WorkflowSection } from "./task-create-dialog-form-body";
vi.mock("@/components/workflow-selector-row", () => ({
WorkflowSelectorRow: ({ selectedWorkflowId }: { selectedWorkflowId: string | null }) => (
<button type="button">Workflow selector {selectedWorkflowId ?? "none"}</button>
),
}));
const workflow = { id: "wf-1", name: "Development" };
function renderWorkflowSection(effectiveWorkflowId: string | null) {
return render(
<WorkflowSection
isCreateMode={true}
isTaskStarted={false}
workflows={[workflow]}
snapshots={{}}
effectiveWorkflowId={effectiveWorkflowId}
onWorkflowChange={() => {}}
agentProfiles={[]}
/>,
);
}
describe("WorkflowSection", () => {
it("keeps the selector reachable when no effective workflow is selected", () => {
renderWorkflowSection(null);
expect(screen.getByRole("button", { name: /workflow selector none/i })).toBeTruthy();
});
it("does not show redundant selector for a selected single workflow without overrides", () => {
const { container } = renderWorkflowSection("wf-1");
expect(container.textContent).toBe("");
});
});
@@ -348,8 +348,21 @@ export const WorkflowSection = memo(function WorkflowSection({
if (!isCreateMode || isTaskStarted) return null; if (!isCreateMode || isTaskStarted) return null;
// Single workflow — show agent override info if any overrides exist if (!effectiveWorkflowId || workflows.length > 1) {
if (workflows.length <= 1) { return (
<WorkflowSelectorRow
workflows={workflows}
snapshots={snapshots}
selectedWorkflowId={effectiveWorkflowId ?? null}
onWorkflowChange={handleWorkflowChange}
lastUsedWorkflowId={lastUsedWorkflowId}
agentProfiles={agentProfiles}
/>
);
}
// Single selected workflow — show agent override info if any overrides exist
if (workflows.length === 1) {
const singleWorkflow = workflows[0]; const singleWorkflow = workflows[0];
if (!singleWorkflow) return null; if (!singleWorkflow) return null;
const snapshot = snapshots[singleWorkflow.id]; const snapshot = snapshots[singleWorkflow.id];
@@ -386,16 +399,7 @@ export const WorkflowSection = memo(function WorkflowSection({
); );
} }
return ( return null;
<WorkflowSelectorRow
workflows={workflows}
snapshots={snapshots}
selectedWorkflowId={effectiveWorkflowId ?? null}
onWorkflowChange={handleWorkflowChange}
lastUsedWorkflowId={lastUsedWorkflowId}
agentProfiles={agentProfiles}
/>
);
}); });
export type DialogPromptSectionProps = { export type DialogPromptSectionProps = {
@@ -0,0 +1,42 @@
"use client";
import { useEffect } from "react";
import type {
DialogFormState,
TaskCreateDialogInitialValues,
} from "@/components/task-create-dialog-types";
type LockedFieldFormState = Pick<
DialogFormState,
| "selectedWorkflowId"
| "setSelectedWorkflowId"
| "repositoryId"
| "setRepositoryId"
| "branch"
| "setBranch"
>;
// Pushes late-arriving locked field values into form state when async feature
// wrappers resolve them after the dialog is already open.
export function useLockedFieldSync(
open: boolean,
workflowId: string | null,
initialValues: TaskCreateDialogInitialValues | undefined,
fs: LockedFieldFormState,
) {
const repoId = initialValues?.repositoryId;
const branch = initialValues?.branch;
useEffect(() => {
if (!open) return;
if (workflowId && workflowId !== fs.selectedWorkflowId) {
fs.setSelectedWorkflowId(workflowId);
}
if (repoId && repoId !== fs.repositoryId) {
fs.setRepositoryId(repoId);
}
if (branch && branch !== fs.branch) {
fs.setBranch(branch);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, workflowId, repoId, branch]);
}
@@ -0,0 +1,83 @@
import { describe, expect, it } from "vitest";
import { computeDialogDefaultStepId } from "./task-create-dialog-defaults";
import type { WorkflowSnapshotData } from "@/lib/state/slices/kanban/types";
function snapshot(workflowId: string): WorkflowSnapshotData {
return {
workflowId,
workflowName: workflowId,
steps: [
{
id: `${workflowId}-later`,
title: "Later",
color: "gray",
position: 2,
},
{
id: `${workflowId}-start`,
title: "Start",
color: "green",
position: 1,
is_start_step: true,
},
],
tasks: [],
};
}
describe("computeDialogDefaultStepId", () => {
it("uses the resolved workflow when falling back to snapshot steps", () => {
expect(
computeDialogDefaultStepId({
selectedWorkflowId: null,
workflowId: "provided",
fetchedSteps: null,
defaultStepId: null,
effectiveWorkflowId: "provided",
snapshots: {
provided: snapshot("provided"),
single: snapshot("single"),
},
}),
).toBe("provided-start");
});
it("falls back to the lowest-position snapshot step when no start step exists", () => {
expect(
computeDialogDefaultStepId({
selectedWorkflowId: null,
workflowId: "provided",
fetchedSteps: null,
defaultStepId: null,
effectiveWorkflowId: "provided",
snapshots: {
provided: {
workflowId: "provided",
workflowName: "provided",
steps: [
{ id: "provided-2", title: "Two", color: "gray", position: 2 },
{ id: "provided-1", title: "One", color: "green", position: 1 },
],
tasks: [],
},
},
}),
).toBe("provided-1");
});
it("ignores a stale default step while a newly selected workflow loads", () => {
expect(
computeDialogDefaultStepId({
selectedWorkflowId: "selected",
workflowId: "original",
fetchedSteps: null,
defaultStepId: "original-start",
effectiveWorkflowId: "selected",
snapshots: {
original: snapshot("original"),
selected: snapshot("selected"),
},
}),
).toBe("selected-start");
});
});
+19 -34
View File
@@ -23,16 +23,18 @@ import type {
DialogComputedValues, DialogComputedValues,
DialogComputedArgs, DialogComputedArgs,
} from "@/components/task-create-dialog-types"; } from "@/components/task-create-dialog-types";
import { computePassthroughProfile } from "@/components/task-create-dialog-helpers";
import { import {
computePassthroughProfile, computeDialogDefaultStepId,
computeEffectiveStepId, computeSingleWorkflowFallbackId,
} from "@/components/task-create-dialog-helpers"; } from "@/components/task-create-dialog-defaults";
export type { export type {
StepType, StepType,
TaskCreateDialogInitialValues, TaskCreateDialogInitialValues,
} from "@/components/task-create-dialog-types"; } from "@/components/task-create-dialog-types";
export { autoSelectBranch } from "@/components/task-create-dialog-helpers"; export { autoSelectBranch } from "@/components/task-create-dialog-helpers";
export { useLockedFieldSync } from "@/components/task-create-dialog-locked-fields";
type FormResetters = { type FormResetters = {
setTaskName: (v: string) => void; setTaskName: (v: string) => void;
@@ -405,32 +407,6 @@ export function useDialogFormState(
return { ...form, ...discovery, ...ghUrl, ...wfAgent, ...freshBranch, clearDraft }; return { ...form, ...discovery, ...ghUrl, ...wfAgent, ...freshBranch, clearDraft };
} }
// Pushes late-arriving locked field values (workflow id, repository id, branch)
// into form state when they change after the dialog is already open. Used by
// feature wrappers like Improve Kandev that resolve these values asynchronously.
export function useLockedFieldSync(
open: boolean,
workflowId: string | null,
initialValues: TaskCreateDialogInitialValues | undefined,
fs: ReturnType<typeof useDialogFormState>,
) {
const repoId = initialValues?.repositoryId;
const branch = initialValues?.branch;
useEffect(() => {
if (!open) return;
if (workflowId && workflowId !== fs.selectedWorkflowId) {
fs.setSelectedWorkflowId(workflowId);
}
if (repoId && repoId !== fs.repositoryId) {
fs.setRepositoryId(repoId);
}
if (branch && branch !== fs.branch) {
fs.setBranch(branch);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, workflowId, repoId, branch]);
}
export type { DialogFormState } from "@/components/task-create-dialog-types"; export type { DialogFormState } from "@/components/task-create-dialog-types";
export { export {
computePassthroughProfile, computePassthroughProfile,
@@ -454,8 +430,14 @@ export function useDialogComputed({
executors, executors,
repositories, repositories,
workflows, workflows,
snapshots,
}: DialogComputedArgs): DialogComputedValues { }: DialogComputedArgs): DialogComputedValues {
const effectiveWorkflowId = fs.selectedWorkflowId ?? workflowId; const singleWorkflowId = computeSingleWorkflowFallbackId(
fs.selectedWorkflowId,
workflowId,
workflows,
);
const effectiveWorkflowId = fs.selectedWorkflowId ?? workflowId ?? singleWorkflowId;
// Compute workflow agent lock directly from data — avoids effect timing issues. // Compute workflow agent lock directly from data — avoids effect timing issues.
const workflowAgentProfileId = (() => { const workflowAgentProfileId = (() => {
const wfId = effectiveWorkflowId; const wfId = effectiveWorkflowId;
@@ -471,12 +453,14 @@ export function useDialogComputed({
() => computePassthroughProfile(effectiveAgentProfileId, agentProfiles), () => computePassthroughProfile(effectiveAgentProfileId, agentProfiles),
[effectiveAgentProfileId, agentProfiles], [effectiveAgentProfileId, agentProfiles],
); );
const effectiveDefaultStepId = computeEffectiveStepId( const effectiveDefaultStepId = computeDialogDefaultStepId({
fs.selectedWorkflowId, selectedWorkflowId: fs.selectedWorkflowId,
workflowId, workflowId,
fs.fetchedSteps, fetchedSteps: fs.fetchedSteps,
defaultStepId, defaultStepId,
); effectiveWorkflowId,
snapshots,
});
const workspaceDefaults = workspaceId const workspaceDefaults = workspaceId
? workspaces.find((ws: Workspace) => ws.id === workspaceId) ? workspaces.find((ws: Workspace) => ws.id === workspaceId)
: null; : null;
@@ -578,6 +562,7 @@ export function useTaskCreateDialogData(
executors, executors,
repositories, repositories,
workflows, workflows,
snapshots,
}); });
return { return {
workflows, workflows,
@@ -7,6 +7,7 @@ import type {
Task, Task,
} from "@/lib/types/http"; } from "@/lib/types/http";
import type { AgentProfileOption } from "@/lib/state/slices"; import type { AgentProfileOption } from "@/lib/state/slices";
import type { WorkflowSnapshotData } from "@/lib/state/slices/kanban/types";
import type { import type {
useRepositoryOptions, useRepositoryOptions,
useBranchOptions, useBranchOptions,
@@ -79,6 +80,7 @@ export type DialogComputedArgs = {
executors: Executor[]; executors: Executor[];
repositories: Repository[]; repositories: Repository[];
workflows: Array<{ id: string; agent_profile_id?: string }>; workflows: Array<{ id: string; agent_profile_id?: string }>;
snapshots: Record<string, WorkflowSnapshotData>;
}; };
export type TaskCreateEffectsArgs = { export type TaskCreateEffectsArgs = {
+1 -1
View File
@@ -118,7 +118,7 @@ export function ChangesTab(props: IDockviewPanelHeaderProps) {
return ( return (
<ContextMenu> <ContextMenu>
<ContextMenuTrigger className="flex h-full items-center"> <ContextMenuTrigger className="flex h-full items-center cursor-pointer">
<div className={cn("relative", isFlashing && "animate-changes-flash")}> <div className={cn("relative", isFlashing && "animate-changes-flash")}>
<DockviewDefaultTab {...props} /> <DockviewDefaultTab {...props} />
{badgeCount > 0 && ( {badgeCount > 0 && (
@@ -0,0 +1,113 @@
"use client";
import {
IconBrandVscode,
IconDeviceDesktop,
IconFileText,
IconFolder,
IconGitBranch,
IconGitPullRequest,
IconMessagePlus,
IconTerminal2,
} from "@tabler/icons-react";
import { DropdownMenuItem, DropdownMenuSeparator } from "@kandev/ui/dropdown-menu";
import { prPanelLabel } from "@/components/github/pr-utils";
import { useDockviewStore } from "@/lib/state/dockview-store";
import type { TaskPR } from "@/lib/types/github";
import { RepositoryScriptsMenuItems } from "./repository-scripts-menu";
import { SessionReopenMenuItems } from "./session-reopen-menu";
type AddPanelMenuState = {
taskId: string | null;
isPassthrough: boolean;
hasChanges: boolean;
hasFiles: boolean;
pr: TaskPR | null;
};
type AddPanelMenuItemsProps = {
groupId: string;
state: AddPanelMenuState;
onNewSession: () => void;
onAddTerminal: () => void;
onRunScript: (scriptId: string) => void;
onRunDevScript: () => void;
};
export const MENU_ICON_CLASS = "h-3.5 w-3.5 mr-1.5 shrink-0";
export const MENU_ITEM_CLASS = "cursor-pointer text-xs";
export function AddPanelMenuItems({
groupId,
state,
onNewSession,
onAddTerminal,
onRunScript,
onRunDevScript,
}: AddPanelMenuItemsProps) {
const addBrowserPanel = useDockviewStore((s) => s.addBrowserPanel);
const addVscodePanel = useDockviewStore((s) => s.addVscodePanel);
const addPlanPanel = useDockviewStore((s) => s.addPlanPanel);
const addFilesPanel = useDockviewStore((s) => s.addFilesPanel);
const addChangesPanel = useDockviewStore((s) => s.addChangesPanel);
const addPRPanel = useDockviewStore((s) => s.addPRPanel);
return (
<>
{state.taskId && (
<>
<DropdownMenuItem
onClick={onNewSession}
className={MENU_ITEM_CLASS}
data-testid="new-session-button"
>
<IconMessagePlus className={MENU_ICON_CLASS} />
New Agent
</DropdownMenuItem>
<DropdownMenuSeparator />
<SessionReopenMenuItems taskId={state.taskId} groupId={groupId} />
</>
)}
<DropdownMenuItem onClick={onAddTerminal} className={MENU_ITEM_CLASS}>
<IconTerminal2 className={MENU_ICON_CLASS} />
Terminal
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => addBrowserPanel(undefined, groupId)}
className={MENU_ITEM_CLASS}
>
<IconDeviceDesktop className={MENU_ICON_CLASS} />
Browser
</DropdownMenuItem>
<DropdownMenuItem onClick={() => addVscodePanel()} className={MENU_ITEM_CLASS}>
<IconBrandVscode className={MENU_ICON_CLASS} />
VS Code
</DropdownMenuItem>
{!state.isPassthrough && (
<DropdownMenuItem onClick={() => addPlanPanel({ groupId })} className={MENU_ITEM_CLASS}>
<IconFileText className={MENU_ICON_CLASS} />
Plan
</DropdownMenuItem>
)}
{!state.hasChanges && (
<DropdownMenuItem onClick={() => addChangesPanel(groupId)} className={MENU_ITEM_CLASS}>
<IconGitBranch className={MENU_ICON_CLASS} />
Changes
</DropdownMenuItem>
)}
{!state.hasFiles && (
<DropdownMenuItem onClick={() => addFilesPanel(groupId)} className={MENU_ITEM_CLASS}>
<IconFolder className={MENU_ICON_CLASS} />
Files
</DropdownMenuItem>
)}
{state.pr && (
<DropdownMenuItem onClick={() => addPRPanel()} className={MENU_ITEM_CLASS}>
<IconGitPullRequest className={MENU_ICON_CLASS} />
{prPanelLabel(state.pr.pr_number)}
</DropdownMenuItem>
)}
<RepositoryScriptsMenuItems onRunScript={onRunScript} onRunDevScript={onRunDevScript} />
</>
);
}
@@ -4,16 +4,10 @@ import { useCallback, useState } from "react";
import { type IDockviewHeaderActionsProps } from "dockview-react"; import { type IDockviewHeaderActionsProps } from "dockview-react";
import { import {
IconPlus, IconPlus,
IconMessagePlus,
IconDeviceDesktop, IconDeviceDesktop,
IconTerminal2, IconTerminal2,
IconFileText,
IconFolder,
IconGitBranch,
IconGitPullRequest,
IconPlayerPlay, IconPlayerPlay,
IconLayoutSidebarRightCollapse, IconLayoutSidebarRightCollapse,
IconBrandVscode,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button"; import { Button } from "@kandev/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
@@ -21,25 +15,29 @@ import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@kandev/ui/dropdown-menu"; } from "@kandev/ui/dropdown-menu";
import { useDockviewStore, performLayoutSwitch } from "@/lib/state/dockview-store"; import { useDockviewStore, performLayoutSwitch } from "@/lib/state/dockview-store";
import { useAppStore, useAppStoreApi } from "@/components/state-provider"; import { useAppStore, useAppStoreApi } from "@/components/state-provider";
import { useActiveTaskPR } from "@/hooks/domains/github/use-task-pr"; import { useActiveTaskPR } from "@/hooks/domains/github/use-task-pr";
import { prPanelLabel } from "@/components/github/pr-utils";
import { startProcess } from "@/lib/api"; import { startProcess } from "@/lib/api";
import { createUserShell } from "@/lib/api/domains/user-shell-api"; import { createUserShell } from "@/lib/api/domains/user-shell-api";
import { useRepositoryScripts } from "@/hooks/domains/workspace/use-repository-scripts"; import { useRepositoryScripts } from "@/hooks/domains/workspace/use-repository-scripts";
import { replaceTaskUrl } from "@/lib/links"; import { replaceTaskUrl } from "@/lib/links";
import type { Task, ProcessInfo } from "@/lib/types/http"; import type { Task, ProcessInfo } from "@/lib/types/http";
import type { ProcessStatusEntry } from "@/lib/state/slices"; import type { ProcessStatusEntry } from "@/lib/state/slices";
import { AddPanelMenuItems, MENU_ICON_CLASS, MENU_ITEM_CLASS } from "./dockview-add-panel-items";
import { NewSessionDialog } from "./new-session-dialog"; import { NewSessionDialog } from "./new-session-dialog";
import { NewTaskDropdown } from "./new-task-dropdown"; import { NewTaskDropdown } from "./new-task-dropdown";
import { RepositoryScriptsMenuItems, useActiveSessionDevScript } from "./repository-scripts-menu"; import { useActiveSessionDevScript } from "./repository-scripts-menu";
import { SessionReopenMenuItems } from "./session-reopen-menu";
import { GroupSplitCloseActionsView, useDockviewGroupWidth } from "./dockview-group-actions"; import { GroupSplitCloseActionsView, useDockviewGroupWidth } from "./dockview-group-actions";
const HEADER_ACTION_BUTTON_CLASS =
"h-6 w-6 p-0 cursor-pointer text-muted-foreground hover:bg-muted/70 hover:text-foreground focus-visible:ring-1 focus-visible:ring-ring";
const RAW_HEADER_ACTION_BUTTON_CLASS =
"inline-flex h-6 w-6 items-center justify-center rounded-[5px] text-muted-foreground transition-colors hover:bg-muted/70 hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring cursor-pointer";
const HEADER_ICON_CLASS = "h-3.5 w-3.5";
/** Map a ProcessInfo response to a ProcessStatusEntry for the store. */ /** Map a ProcessInfo response to a ProcessStatusEntry for the store. */
function mapProcessToStatus(process: ProcessInfo): ProcessStatusEntry { function mapProcessToStatus(process: ProcessInfo): ProcessStatusEntry {
return { return {
@@ -85,94 +83,6 @@ function useLeftHeaderState(
}; };
} }
function AddPanelMenuItems({
groupId,
state,
onNewSession,
onAddTerminal,
onRunScript,
onRunDevScript,
}: {
groupId: string;
state: ReturnType<typeof useLeftHeaderState>;
onNewSession: () => void;
onAddTerminal: () => void;
onRunScript: (scriptId: string) => void;
onRunDevScript: () => void;
}) {
const addBrowserPanel = useDockviewStore((s) => s.addBrowserPanel);
const addVscodePanel = useDockviewStore((s) => s.addVscodePanel);
const addPlanPanel = useDockviewStore((s) => s.addPlanPanel);
const addFilesPanel = useDockviewStore((s) => s.addFilesPanel);
const addChangesPanel = useDockviewStore((s) => s.addChangesPanel);
const addPRPanel = useDockviewStore((s) => s.addPRPanel);
return (
<>
{state.taskId && (
<>
<DropdownMenuItem
onClick={onNewSession}
className="cursor-pointer text-xs"
data-testid="new-session-button"
>
<IconMessagePlus className="h-3.5 w-3.5 mr-1.5" />
New Agent
</DropdownMenuItem>
<DropdownMenuSeparator />
<SessionReopenMenuItems taskId={state.taskId} groupId={groupId} />
</>
)}
<DropdownMenuItem onClick={onAddTerminal} className="cursor-pointer text-xs">
<IconTerminal2 className="h-3.5 w-3.5 mr-1.5" />
Terminal
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => addBrowserPanel(undefined, groupId)}
className="cursor-pointer text-xs"
>
<IconDeviceDesktop className="h-3.5 w-3.5 mr-1.5" />
Browser
</DropdownMenuItem>
<DropdownMenuItem onClick={() => addVscodePanel()} className="cursor-pointer text-xs">
<IconBrandVscode className="h-3.5 w-3.5 mr-1.5" />
VS Code
</DropdownMenuItem>
{!state.isPassthrough && (
<DropdownMenuItem
onClick={() => addPlanPanel({ groupId })}
className="cursor-pointer text-xs"
>
<IconFileText className="h-3.5 w-3.5 mr-1.5" />
Plan
</DropdownMenuItem>
)}
{!state.hasChanges && (
<DropdownMenuItem
onClick={() => addChangesPanel(groupId)}
className="cursor-pointer text-xs"
>
<IconGitBranch className="h-3.5 w-3.5 mr-1.5" />
Changes
</DropdownMenuItem>
)}
{!state.hasFiles && (
<DropdownMenuItem onClick={() => addFilesPanel(groupId)} className="cursor-pointer text-xs">
<IconFolder className="h-3.5 w-3.5 mr-1.5" />
Files
</DropdownMenuItem>
)}
{state.pr && (
<DropdownMenuItem onClick={() => addPRPanel()} className="cursor-pointer text-xs">
<IconGitPullRequest className="h-3.5 w-3.5 mr-1.5" />
{prPanelLabel(state.pr.pr_number)}
</DropdownMenuItem>
)}
<RepositoryScriptsMenuItems onRunScript={onRunScript} onRunDevScript={onRunDevScript} />
</>
);
}
export function LeftHeaderActions(props: IDockviewHeaderActionsProps) { export function LeftHeaderActions(props: IDockviewHeaderActionsProps) {
const { group, containerApi } = props; const { group, containerApi } = props;
const state = useLeftHeaderState(group.id, containerApi); const state = useLeftHeaderState(group.id, containerApi);
@@ -219,16 +129,18 @@ export function LeftHeaderActions(props: IDockviewHeaderActionsProps) {
if (state.isSidebarGroup) return null; if (state.isSidebarGroup) return null;
return ( return (
<div className="flex items-center gap-1 pl-1"> <div className="flex items-center gap-0.5 pl-0.5">
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button <Button
size="sm" size="sm"
variant="ghost" variant="ghost"
className="h-6 w-6 p-0 cursor-pointer" className={HEADER_ACTION_BUTTON_CLASS}
data-testid="dockview-add-panel-btn" data-testid="dockview-add-panel-btn"
aria-label="Add panel"
title="Add panel"
> >
<IconPlus className="h-3.5 w-3.5" /> <IconPlus className={HEADER_ICON_CLASS} />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-44"> <DropdownMenuContent align="start" className="w-44">
@@ -326,7 +238,7 @@ export function RightHeaderActions(props: IDockviewHeaderActionsProps) {
const isTerminalGroup = group.id === rightBottomGroupId; const isTerminalGroup = group.id === rightBottomGroupId;
return ( return (
<div className="flex items-center gap-0.5 pr-1"> <div className="flex items-center gap-0.5 pr-0.5">
{isCenterGroup && <CenterRightActions />} {isCenterGroup && <CenterRightActions />}
{isRightTopGroup && <RightTopGroupActions />} {isRightTopGroup && <RightTopGroupActions />}
{isTerminalGroup && <TerminalGroupRightActions />} {isTerminalGroup && <TerminalGroupRightActions />}
@@ -402,10 +314,11 @@ function RightTopGroupActions() {
<TooltipTrigger asChild> <TooltipTrigger asChild>
<button <button
type="button" type="button"
className="h-6 w-6 inline-flex items-center justify-center text-muted-foreground/50 hover:text-foreground transition-colors cursor-pointer" className={RAW_HEADER_ACTION_BUTTON_CLASS}
onClick={toggleRightPanels} onClick={toggleRightPanels}
aria-label="Hide right panels"
> >
<IconLayoutSidebarRightCollapse className="h-3.5 w-3.5" /> <IconLayoutSidebarRightCollapse className={HEADER_ICON_CLASS} />
</button> </button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent>Hide right panels</TooltipContent> <TooltipContent>Hide right panels</TooltipContent>
@@ -452,11 +365,12 @@ function CenterRightActions() {
<Button <Button
size="sm" size="sm"
variant="ghost" variant="ghost"
className="h-6 w-6 p-0 cursor-pointer" className={HEADER_ACTION_BUTTON_CLASS}
onClick={handleStartBrowser} onClick={handleStartBrowser}
aria-label="Open browser preview"
title="Open browser preview" title="Open browser preview"
> >
<IconDeviceDesktop className="h-3.5 w-3.5" /> <IconDeviceDesktop className={HEADER_ICON_CLASS} />
</Button> </Button>
)} )}
</div> </div>
@@ -471,20 +385,46 @@ function TerminalGroupRightActions() {
return state.taskSessions.items[sessionId]?.repository_id ?? null; return state.taskSessions.items[sessionId]?.repository_id ?? null;
}); });
const hasDevScript = Boolean(useActiveSessionDevScript()); const hasDevScript = Boolean(useActiveSessionDevScript());
const { scripts } = useRepositoryScripts(repositoryId); const { scripts } = useRepositoryScripts(repositoryId);
const addTerminalPanel = useDockviewStore((s) => s.addTerminalPanel);
const addBrowserPanel = useDockviewStore((s) => s.addBrowserPanel);
const upsertProcessStatus = useAppStore((state) => state.upsertProcessStatus);
const setActiveProcess = useAppStore((state) => state.setActiveProcess);
const rightBottomGroupId = useDockviewStore((s) => s.rightBottomGroupId); const rightBottomGroupId = useDockviewStore((s) => s.rightBottomGroupId);
if (scripts.length === 0 && !hasDevScript) return null;
return (
<>
<TerminalScriptsDropdown
scripts={scripts}
activeSessionId={activeSessionId}
rightBottomGroupId={rightBottomGroupId}
/>
<TerminalDevPreviewButton
activeSessionId={activeSessionId}
rightBottomGroupId={rightBottomGroupId}
visible={hasDevScript}
/>
</>
);
}
type TerminalScriptsDropdownProps = {
scripts: ReturnType<typeof useRepositoryScripts>["scripts"];
activeSessionId: string | null;
rightBottomGroupId: string | null;
};
function TerminalScriptsDropdown({
scripts,
activeSessionId,
rightBottomGroupId,
}: TerminalScriptsDropdownProps) {
const addTerminalPanel = useDockviewStore((s) => s.addTerminalPanel);
const handleRunScript = useCallback( const handleRunScript = useCallback(
async (scriptId: string) => { async (scriptId: string) => {
if (!activeSessionId) return; if (!activeSessionId) return;
try { try {
const result = await createUserShell(activeSessionId, { scriptId }); const result = await createUserShell(activeSessionId, { scriptId });
addTerminalPanel(result.terminalId, rightBottomGroupId); addTerminalPanel(result.terminalId, rightBottomGroupId ?? undefined);
} catch (error) { } catch (error) {
console.error("Failed to run script:", error); console.error("Failed to run script:", error);
} }
@@ -492,6 +432,56 @@ function TerminalGroupRightActions() {
[activeSessionId, addTerminalPanel, rightBottomGroupId], [activeSessionId, addTerminalPanel, rightBottomGroupId],
); );
if (scripts.length === 0) return null;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="sm"
variant="ghost"
className={HEADER_ACTION_BUTTON_CLASS}
aria-label="Run script"
title="Run script"
>
<IconPlayerPlay className={HEADER_ICON_CLASS} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-52">
{scripts.map((script) => (
<DropdownMenuItem
key={script.id}
onClick={() => handleRunScript(script.id)}
className={MENU_ITEM_CLASS}
>
<IconTerminal2 className={MENU_ICON_CLASS} />
<span className="truncate">{script.name}</span>
<span className="ml-auto text-muted-foreground font-mono text-[10px] truncate max-w-[120px]">
{script.command}
</span>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}
type TerminalDevPreviewButtonProps = {
activeSessionId: string | null;
rightBottomGroupId: string | null;
visible: boolean;
};
function TerminalDevPreviewButton({
activeSessionId,
rightBottomGroupId,
visible,
}: TerminalDevPreviewButtonProps) {
const addBrowserPanel = useDockviewStore((s) => s.addBrowserPanel);
const addTerminalPanel = useDockviewStore((s) => s.addTerminalPanel);
const upsertProcessStatus = useAppStore((state) => state.upsertProcessStatus);
const setActiveProcess = useAppStore((state) => state.setActiveProcess);
const handleStartPreview = useCallback(async () => { const handleStartPreview = useCallback(async () => {
if (!activeSessionId) return; if (!activeSessionId) return;
addBrowserPanel(); addBrowserPanel();
@@ -506,7 +496,7 @@ function TerminalGroupRightActions() {
} }
try { try {
const shell = await createUserShell(activeSessionId); const shell = await createUserShell(activeSessionId);
addTerminalPanel(shell.terminalId, rightBottomGroupId); addTerminalPanel(shell.terminalId, rightBottomGroupId ?? undefined);
} catch { } catch {
// Terminal creation is best-effort // Terminal creation is best-effort
} }
@@ -519,50 +509,18 @@ function TerminalGroupRightActions() {
rightBottomGroupId, rightBottomGroupId,
]); ]);
if (scripts.length === 0 && !hasDevScript) return null; if (!visible) return null;
return ( return (
<> <Button
{scripts.length > 0 && ( size="sm"
<DropdownMenu> variant="ghost"
<DropdownMenuTrigger asChild> className={HEADER_ACTION_BUTTON_CLASS}
<Button onClick={handleStartPreview}
size="sm" aria-label="Start dev server preview"
variant="ghost" title="Start dev server preview"
className="h-6 w-6 p-0 cursor-pointer" >
title="Run script" <IconDeviceDesktop className={HEADER_ICON_CLASS} />
> </Button>
<IconPlayerPlay className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-52">
{scripts.map((script) => (
<DropdownMenuItem
key={script.id}
onClick={() => handleRunScript(script.id)}
className="cursor-pointer text-xs"
>
<IconTerminal2 className="h-3.5 w-3.5 mr-1.5 shrink-0" />
<span className="truncate">{script.name}</span>
<span className="ml-auto text-muted-foreground font-mono text-[10px] truncate max-w-[120px]">
{script.command}
</span>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
{hasDevScript && (
<Button
size="sm"
variant="ghost"
className="h-6 w-6 p-0 cursor-pointer"
onClick={handleStartPreview}
title="Start dev server preview"
>
<IconDeviceDesktop className="h-3.5 w-3.5" />
</Button>
)}
</>
); );
} }
@@ -55,7 +55,7 @@ export function NewTaskDropdown({
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
className="h-6 px-1 rounded-l-none cursor-pointer" className="-ml-px h-6 rounded-l-none px-1 cursor-pointer"
data-testid="new-task-chevron" data-testid="new-task-chevron"
> >
<IconChevronDown className="h-3 w-3" /> <IconChevronDown className="h-3 w-3" />
+32 -31
View File
@@ -4,11 +4,17 @@ import { cn } from "@kandev/ui/lib/utils";
/** /**
* Reusable dockview panel layout primitives. * Reusable dockview panel layout primitives.
* *
* PanelRoot – outermost wrapper, fills the dockview content area * PanelRoot - outermost wrapper, fills the dockview content area
* PanelBody – scrollable (or non-scrollable) content region * PanelBody - scrollable (or non-scrollable) content region
* PanelToolbar – fixed strip at the top/bottom of a panel * PanelToolbar - fixed header strip for panel actions
*/ */
const PANEL_ROOT_CLASS = "h-full min-h-0 flex flex-col bg-card text-card-foreground";
const PANEL_BAR_CLASS =
"flex items-center gap-1.5 h-[30px] px-2.5 shrink-0 border-border/80 bg-card/95 text-xs text-foreground";
const PANEL_ACTION_CURSOR_CLASS =
"[&_button:not(:disabled)]:cursor-pointer [&_[role=button]:not([aria-disabled=true])]:cursor-pointer";
type PanelRootProps = HTMLAttributes<HTMLDivElement> & { type PanelRootProps = HTMLAttributes<HTMLDivElement> & {
children: ReactNode; children: ReactNode;
className?: string; className?: string;
@@ -20,7 +26,7 @@ export const PanelRoot = forwardRef<HTMLDivElement, PanelRootProps>(function Pan
ref, ref,
) { ) {
return ( return (
<div ref={ref} className={cn("h-full flex flex-col min-h-0", className)} {...rest}> <div ref={ref} className={cn(PANEL_ROOT_CLASS, className)} {...rest}>
{children} {children}
</div> </div>
); );
@@ -29,7 +35,7 @@ export const PanelRoot = forwardRef<HTMLDivElement, PanelRootProps>(function Pan
type PanelBodyProps = Omit<HTMLAttributes<HTMLDivElement>, "className"> & { type PanelBodyProps = Omit<HTMLAttributes<HTMLDivElement>, "className"> & {
children: ReactNode; children: ReactNode;
className?: string; className?: string;
/** Add default p-3 padding. Default true. */ /** Add default p-2.5 padding. Default true. */
padding?: boolean; padding?: boolean;
/** Enable overflow scrolling. Default true. */ /** Enable overflow scrolling. Default true. */
scroll?: boolean; scroll?: boolean;
@@ -44,9 +50,9 @@ export const PanelBody = forwardRef<HTMLDivElement, PanelBodyProps>(function Pan
<div <div
ref={ref} ref={ref}
className={cn( className={cn(
"flex-1 min-h-0 bg-card", "flex-1 min-h-0 bg-card text-card-foreground",
scroll && "overflow-auto", scroll && "overflow-auto",
padding && "p-3", padding && "p-2.5",
className, className,
)} )}
{...rest} {...rest}
@@ -61,20 +67,25 @@ type PanelToolbarProps = {
className?: string; className?: string;
}; };
/** Fixed toolbar strip. Doesn't scroll with content. */ type PanelBarProps = {
export function PanelToolbar({ children, className }: PanelToolbarProps) { children?: ReactNode;
className?: string;
borderClassName: "border-b" | "border-t";
};
function PanelBar({ children, className, borderClassName }: PanelBarProps) {
return ( return (
<div <div className={cn(PANEL_BAR_CLASS, PANEL_ACTION_CURSOR_CLASS, borderClassName, className)}>
className={cn(
"flex items-center gap-2 px-3 py-1.5 border-b border-border shrink-0",
className,
)}
>
{children} {children}
</div> </div>
); );
} }
/** Fixed header toolbar strip. Doesn't scroll with content. */
export function PanelToolbar({ children, className }: PanelToolbarProps) {
return <PanelHeaderBar className={className}>{children}</PanelHeaderBar>;
}
type PanelHeaderBarProps = { type PanelHeaderBarProps = {
children?: ReactNode; children?: ReactNode;
className?: string; className?: string;
@@ -83,14 +94,9 @@ type PanelHeaderBarProps = {
/** Fixed-height panel header bar. Renders children directly. */ /** Fixed-height panel header bar. Renders children directly. */
export function PanelHeaderBar({ children, className }: PanelHeaderBarProps) { export function PanelHeaderBar({ children, className }: PanelHeaderBarProps) {
return ( return (
<div <PanelBar borderClassName="border-b" className={className}>
className={cn(
"flex items-center gap-2 px-3 h-[30px] border-b border-border shrink-0 bg-card",
className,
)}
>
{children} {children}
</div> </PanelBar>
); );
} }
@@ -104,9 +110,9 @@ type PanelHeaderBarSplitProps = {
export function PanelHeaderBarSplit({ left, right, className }: PanelHeaderBarSplitProps) { export function PanelHeaderBarSplit({ left, right, className }: PanelHeaderBarSplitProps) {
return ( return (
<PanelHeaderBar className={className}> <PanelHeaderBar className={className}>
<div className="flex items-center gap-1.5 min-w-0">{left}</div> <div className="flex items-center gap-1.5 min-w-0 overflow-hidden">{left}</div>
<div className="flex-1" /> <div className="flex-1" />
<div className="flex items-center gap-1.5 min-w-0">{right}</div> <div className="flex items-center gap-1.5 min-w-0 overflow-hidden">{right}</div>
</PanelHeaderBar> </PanelHeaderBar>
); );
} }
@@ -114,13 +120,8 @@ export function PanelHeaderBarSplit({ left, right, className }: PanelHeaderBarSp
/** Fixed-height panel footer bar with border-t. Mirrors PanelHeaderBar but anchors to the bottom. */ /** Fixed-height panel footer bar with border-t. Mirrors PanelHeaderBar but anchors to the bottom. */
export function PanelFooterBar({ children, className }: PanelHeaderBarProps) { export function PanelFooterBar({ children, className }: PanelHeaderBarProps) {
return ( return (
<div <PanelBar borderClassName="border-t" className={className}>
className={cn(
"flex items-center gap-2 px-3 h-[30px] border-t border-border shrink-0 bg-card",
className,
)}
>
{children} {children}
</div> </PanelBar>
); );
} }
+17 -7
View File
@@ -75,7 +75,7 @@ function PreviewTab(props: IDockviewPanelHeaderProps & { type: PreviewType }) {
<ContextMenu> <ContextMenu>
<ContextMenuTrigger asChild> <ContextMenuTrigger asChild>
<div <div
className={cn("flex h-full items-center", !isPromoted && "italic")} className={cn("flex h-full items-center cursor-pointer", !isPromoted && "italic")}
onMouseDown={onMouseDown} onMouseDown={onMouseDown}
onDoubleClick={onDoubleClick} onDoubleClick={onDoubleClick}
title={isPromoted ? undefined : "Double-click to keep this tab open"} title={isPromoted ? undefined : "Double-click to keep this tab open"}
@@ -85,12 +85,18 @@ function PreviewTab(props: IDockviewPanelHeaderProps & { type: PreviewType }) {
</div> </div>
</ContextMenuTrigger> </ContextMenuTrigger>
<ContextMenuContent> <ContextMenuContent>
<ContextMenuItem onSelect={handleClose}>Close</ContextMenuItem> <ContextMenuItem className="cursor-pointer" onSelect={handleClose}>
<ContextMenuItem onSelect={handleCloseOthers}>Close Others</ContextMenuItem> Close
</ContextMenuItem>
<ContextMenuItem className="cursor-pointer" onSelect={handleCloseOthers}>
Close Others
</ContextMenuItem>
{!isPromoted && ( {!isPromoted && (
<> <>
<ContextMenuSeparator /> <ContextMenuSeparator />
<ContextMenuItem onSelect={handleKeepOpen}>Keep Open</ContextMenuItem> <ContextMenuItem className="cursor-pointer" onSelect={handleKeepOpen}>
Keep Open
</ContextMenuItem>
</> </>
)} )}
</ContextMenuContent> </ContextMenuContent>
@@ -120,13 +126,17 @@ export function PinnedDefaultTab(props: IDockviewPanelHeaderProps) {
return ( return (
<ContextMenu> <ContextMenu>
<ContextMenuTrigger asChild> <ContextMenuTrigger asChild>
<div className="flex h-full items-center" onMouseDown={onMouseDown}> <div className="flex h-full items-center cursor-pointer" onMouseDown={onMouseDown}>
<DockviewDefaultTab {...props} /> <DockviewDefaultTab {...props} />
</div> </div>
</ContextMenuTrigger> </ContextMenuTrigger>
<ContextMenuContent> <ContextMenuContent>
<ContextMenuItem onSelect={handleClose}>Close</ContextMenuItem> <ContextMenuItem className="cursor-pointer" onSelect={handleClose}>
<ContextMenuItem onSelect={handleCloseOthers}>Close Others</ContextMenuItem> Close
</ContextMenuItem>
<ContextMenuItem className="cursor-pointer" onSelect={handleCloseOthers}>
Close Others
</ContextMenuItem>
</ContextMenuContent> </ContextMenuContent>
</ContextMenu> </ContextMenu>
); );
+13 -2
View File
@@ -6,9 +6,15 @@ import { KeyboardShortcutTooltip } from "@/components/keyboard-shortcut-tooltip"
import { getShortcut } from "@/lib/keyboard/shortcut-overrides"; import { getShortcut } from "@/lib/keyboard/shortcut-overrides";
import { useQuickChatLauncher } from "@/hooks/use-quick-chat-launcher"; import { useQuickChatLauncher } from "@/hooks/use-quick-chat-launcher";
import { useAppStore } from "@/components/state-provider"; import { useAppStore } from "@/components/state-provider";
import type { ComponentProps } from "react";
type QuickChatButtonProps = {
workspaceId?: string | null;
size?: ComponentProps<typeof Button>["size"];
};
/** Quick Chat button that opens the quick chat modal */ /** Quick Chat button that opens the quick chat modal */
export function QuickChatButton({ workspaceId }: { workspaceId?: string | null }) { export function QuickChatButton({ workspaceId, size = "default" }: QuickChatButtonProps) {
const handleOpenQuickChat = useQuickChatLauncher(workspaceId); const handleOpenQuickChat = useQuickChatLauncher(workspaceId);
const keyboardShortcuts = useAppStore((s) => s.userSettings.keyboardShortcuts); const keyboardShortcuts = useAppStore((s) => s.userSettings.keyboardShortcuts);
const quickChatShortcut = getShortcut("QUICK_CHAT", keyboardShortcuts); const quickChatShortcut = getShortcut("QUICK_CHAT", keyboardShortcuts);
@@ -17,7 +23,12 @@ export function QuickChatButton({ workspaceId }: { workspaceId?: string | null }
return ( return (
<KeyboardShortcutTooltip shortcut={quickChatShortcut} description="Quick Chat"> <KeyboardShortcutTooltip shortcut={quickChatShortcut} description="Quick Chat">
<Button variant="outline" className="cursor-pointer gap-2" onClick={handleOpenQuickChat}> <Button
variant="outline"
size={size}
className="cursor-pointer gap-2"
onClick={handleOpenQuickChat}
>
<IconMessageCircle className="h-4 w-4" /> <IconMessageCircle className="h-4 w-4" />
Chat Chat
</Button> </Button>
+1 -1
View File
@@ -272,7 +272,7 @@ export function SessionTab(props: IDockviewPanelHeaderProps) {
<> <>
<ContextMenu> <ContextMenu>
<ContextMenuTrigger <ContextMenuTrigger
className="flex h-full items-center" className="flex h-full items-center cursor-pointer"
data-testid={sessionId ? `session-tab-${sessionId}` : undefined} data-testid={sessionId ? `session-tab-${sessionId}` : undefined}
> >
<div className="flex items-center"> <div className="flex items-center">
+264 -115
View File
@@ -1,8 +1,8 @@
"use client"; "use client";
import { memo, useCallback } from "react"; import { memo, useCallback, type ReactNode } from "react";
import Link from "next/link"; import Link from "next/link";
import { IconBug, IconHome, IconSettings } from "@tabler/icons-react"; import { IconBug, IconDots, IconHome, IconSettings } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button"; import { Button } from "@kandev/ui/button";
import { import {
Breadcrumb, Breadcrumb,
@@ -12,8 +12,15 @@ import {
BreadcrumbPage, BreadcrumbPage,
BreadcrumbSeparator, BreadcrumbSeparator,
} from "@kandev/ui/breadcrumb"; } from "@kandev/ui/breadcrumb";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@kandev/ui/dropdown-menu";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { CommitStatBadge } from "@/components/diff-stat";
import { useSessionGit } from "@/hooks/domains/session/use-session-git"; import { useSessionGit } from "@/hooks/domains/session/use-session-git";
import { EditorsMenu } from "@/components/task/editors-menu"; import { EditorsMenu } from "@/components/task/editors-menu";
import { BranchPathPopover } from "@/components/task/branch-path-popover"; import { BranchPathPopover } from "@/components/task/branch-path-popover";
@@ -31,6 +38,11 @@ import { PortForwardButton } from "@/components/task/port-forward-dialog";
import { WorkflowStepper, type WorkflowStepperStep } from "@/components/task/workflow-stepper"; import { WorkflowStepper, type WorkflowStepperStep } from "@/components/task/workflow-stepper";
import { RemoteCloudTooltip } from "@/components/task/remote-cloud-tooltip"; import { RemoteCloudTooltip } from "@/components/task/remote-cloud-tooltip";
import { QuickChatButton } from "@/components/task/quick-chat-button"; import { QuickChatButton } from "@/components/task/quick-chat-button";
import {
TopbarActionOverflow,
type TopbarOverflowItem,
} from "@/components/task/topbar-action-overflow";
import { IntegrationsMenu } from "@/components/integrations/integrations-menu";
import { DEBUG_UI } from "@/lib/config"; import { DEBUG_UI } from "@/lib/config";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -64,6 +76,25 @@ type TaskTopBarProps = {
remoteStatusError?: string | null; remoteStatusError?: string | null;
}; };
type TopBarLeftProps = {
taskId?: string | null;
activeSessionId?: string | null;
taskTitle?: string;
repositoryName?: string | null;
displayBranch?: string;
repositoryPath?: string | null;
worktreePath?: string | null;
isRemoteExecutor?: boolean;
remoteExecutorName?: string | null;
remoteExecutorType?: string | null;
remoteState?: string | null;
remoteCreatedAt?: string | null;
remoteCheckedAt?: string | null;
remoteStatusError?: string | null;
workspaceId?: string | null;
onRenameBranch?: (newName: string) => Promise<void>;
};
const TaskTopBar = memo(function TaskTopBar({ const TaskTopBar = memo(function TaskTopBar({
taskId, taskId,
activeSessionId, activeSessionId,
@@ -109,7 +140,10 @@ const TaskTopBar = memo(function TaskTopBar({
); );
return ( return (
<header className="@container/topbar grid grid-cols-[1fr_auto_1fr] items-center px-3 py-1 border-b border-border"> <header
data-testid="task-topbar"
className="@container/topbar grid grid-cols-[minmax(0,1fr)_minmax(0,auto)_minmax(0,1fr)] items-center gap-2 overflow-hidden px-3 py-1 border-b border-border"
>
<TopBarLeft <TopBarLeft
taskId={taskId} taskId={taskId}
activeSessionId={activeSessionId} activeSessionId={activeSessionId}
@@ -125,22 +159,24 @@ const TaskTopBar = memo(function TaskTopBar({
remoteCreatedAt={remoteCreatedAt} remoteCreatedAt={remoteCreatedAt}
remoteCheckedAt={remoteCheckedAt} remoteCheckedAt={remoteCheckedAt}
remoteStatusError={remoteStatusError} remoteStatusError={remoteStatusError}
workspaceId={workspaceId}
onRenameBranch={activeSessionId ? handleRenameBranch : undefined} onRenameBranch={activeSessionId ? handleRenameBranch : undefined}
/> />
{workflowSteps && workflowSteps.length > 0 && ( <div className="min-w-0 justify-self-center overflow-hidden">
<WorkflowStepper {workflowSteps && workflowSteps.length > 0 && (
steps={workflowSteps} <WorkflowStepper
currentStepId={currentStepId ?? null} steps={workflowSteps}
taskId={taskId ?? null} currentStepId={currentStepId ?? null}
workflowId={workflowId ?? null} taskId={taskId ?? null}
isArchived={isArchived} workflowId={workflowId ?? null}
/> isArchived={isArchived}
)} />
)}
</div>
<TopBarRight <TopBarRight
taskId={taskId} taskId={taskId}
activeSessionId={activeSessionId} activeSessionId={activeSessionId}
baseBranch={baseBranch} baseBranch={baseBranch}
gitStatus={git}
showDebugOverlay={showDebugOverlay} showDebugOverlay={showDebugOverlay}
onToggleDebugOverlay={onToggleDebugOverlay} onToggleDebugOverlay={onToggleDebugOverlay}
isArchived={isArchived} isArchived={isArchived}
@@ -203,24 +239,9 @@ function TopBarLeft({
remoteCreatedAt, remoteCreatedAt,
remoteCheckedAt, remoteCheckedAt,
remoteStatusError, remoteStatusError,
workspaceId,
onRenameBranch, onRenameBranch,
}: { }: TopBarLeftProps) {
taskId?: string | null;
activeSessionId?: string | null;
taskTitle?: string;
repositoryName?: string | null;
displayBranch?: string;
repositoryPath?: string | null;
worktreePath?: string | null;
isRemoteExecutor?: boolean;
remoteExecutorName?: string | null;
remoteExecutorType?: string | null;
remoteState?: string | null;
remoteCreatedAt?: string | null;
remoteCheckedAt?: string | null;
remoteStatusError?: string | null;
onRenameBranch?: (newName: string) => Promise<void>;
}) {
return ( return (
<div className="flex items-center gap-2.5 min-w-0 overflow-hidden"> <div className="flex items-center gap-2.5 min-w-0 overflow-hidden">
<Breadcrumb className="min-w-0"> <Breadcrumb className="min-w-0">
@@ -262,6 +283,8 @@ function TopBarLeft({
</BreadcrumbList> </BreadcrumbList>
</Breadcrumb> </Breadcrumb>
<IntegrationsMenu workspaceId={workspaceId ?? undefined} />
<div className="shrink-0 @max-[1352px]/topbar:hidden"> <div className="shrink-0 @max-[1352px]/topbar:hidden">
<BranchPathPopover <BranchPathPopover
displayBranch={displayBranch} displayBranch={displayBranch}
@@ -290,54 +313,162 @@ function TopBarLeft({
); );
} }
/** Ahead/Behind commit status badges */ function TopbarCluster({
function GitAheadBehindBadges({ label,
gitStatus, className = "",
baseBranch, children,
}: { }: {
gitStatus: { ahead: number; behind: number }; label: string;
baseBranch?: string; className?: string;
children: ReactNode;
}) { }) {
const ahead = gitStatus?.ahead ?? 0;
const behind = gitStatus?.behind ?? 0;
if (ahead === 0 && behind === 0) return null;
const compareRef = baseBranch || "main";
return ( return (
<div className="flex items-center gap-1"> <div
{ahead > 0 && ( aria-label={label}
<Tooltip> className={`inline-flex shrink-0 items-center gap-1 [&:empty]:hidden ${className}`}
<TooltipTrigger asChild> >
<span className="cursor-default"> {children}
<CommitStatBadge label={`${ahead} ahead`} tone="ahead" />
</span>
</TooltipTrigger>
<TooltipContent>
{ahead} commit{ahead !== 1 ? "s" : ""} ahead of {compareRef}
</TooltipContent>
</Tooltip>
)}
{behind > 0 && (
<Tooltip>
<TooltipTrigger asChild>
<span className="cursor-default">
<CommitStatBadge label={`${behind} behind`} tone="behind" />
</span>
</TooltipTrigger>
<TooltipContent>
{behind} commit{behind !== 1 ? "s" : ""} behind {compareRef}
</TooltipContent>
</Tooltip>
)}
</div> </div>
); );
} }
/** Right section: git badges, debug toggle, document controls, editors, VCS, settings */ function MoreToolsMenu({
showDebugOverlay,
onToggleDebugOverlay,
}: {
showDebugOverlay?: boolean;
onToggleDebugOverlay?: () => void;
}) {
const showDebugItem = DEBUG_UI && onToggleDebugOverlay;
const debugLabel = showDebugOverlay ? "Hide Debug Info" : "Show Debug Info";
return (
<DropdownMenu>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button
size="sm"
variant="outline"
className="h-8 cursor-pointer px-2"
aria-label="More task tools"
>
<IconDots className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent>More tools</TooltipContent>
</Tooltip>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuLabel className="text-xs">More tools</DropdownMenuLabel>
{showDebugItem && (
<>
<DropdownMenuItem className="cursor-pointer gap-2" onClick={onToggleDebugOverlay}>
<IconBug className="h-4 w-4 text-muted-foreground" />
<span>{debugLabel}</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem asChild className="cursor-pointer gap-2">
<Link href="/settings/general">
<IconSettings className="h-4 w-4 text-muted-foreground" />
<span>Settings</span>
</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
function SettingsButton() {
return (
<Tooltip>
<TooltipTrigger asChild>
<Button asChild size="sm" variant="outline" className="h-8 cursor-pointer px-2">
<Link href="/settings/general" aria-label="Settings">
<IconSettings className="h-4 w-4" />
</Link>
</Button>
</TooltipTrigger>
<TooltipContent>Settings</TooltipContent>
</Tooltip>
);
}
function AttentionStatusGroup({
taskId,
activeSessionId,
isArchived,
workspaceId,
isRemoteExecutor,
isAgentctlReady,
taskTitle,
}: {
taskId?: string | null;
activeSessionId?: string | null;
isArchived?: boolean;
workspaceId?: string | null;
isRemoteExecutor?: boolean;
isAgentctlReady?: boolean;
taskTitle?: string;
}) {
return (
<TopbarCluster label="Task status and attention" className="[&_button]:h-8 [&_button]:text-xs">
<DocumentControls activeSessionId={activeSessionId ?? null} />
{!isArchived && (
<>
<PortForwardButton
isRemoteExecutor={isRemoteExecutor}
sessionId={activeSessionId}
isAgentctlReady={isAgentctlReady}
/>
<PRTopbarButton />
<IssueTrackerButtons taskId={taskId} workspaceId={workspaceId} taskTitle={taskTitle} />
</>
)}
</TopbarCluster>
);
}
function TopbarToolsGroup({
activeSessionId,
showDebugOverlay,
onToggleDebugOverlay,
isArchived,
}: {
activeSessionId?: string | null;
showDebugOverlay?: boolean;
onToggleDebugOverlay?: () => void;
isArchived?: boolean;
}) {
const showDebugMenu = DEBUG_UI && onToggleDebugOverlay;
return (
<TopbarCluster label="Task tools" className="[&_button]:h-8 [&_button]:text-xs">
{!isArchived && (
<>
<LayoutPresetSelector />
<EditorsMenu activeSessionId={activeSessionId ?? null} />
</>
)}
{showDebugMenu ? (
<MoreToolsMenu
showDebugOverlay={showDebugOverlay}
onToggleDebugOverlay={onToggleDebugOverlay}
/>
) : (
<SettingsButton />
)}
</TopbarCluster>
);
}
/** Right section: primary VCS action, status/attention, tools menu */
function TopBarRight({ function TopBarRight({
taskId, taskId,
activeSessionId, activeSessionId,
baseBranch, baseBranch,
gitStatus,
showDebugOverlay, showDebugOverlay,
onToggleDebugOverlay, onToggleDebugOverlay,
isArchived, isArchived,
@@ -349,7 +480,6 @@ function TopBarRight({
taskId?: string | null; taskId?: string | null;
activeSessionId?: string | null; activeSessionId?: string | null;
baseBranch?: string; baseBranch?: string;
gitStatus: { ahead: number; behind: number };
showDebugOverlay?: boolean; showDebugOverlay?: boolean;
onToggleDebugOverlay?: () => void; onToggleDebugOverlay?: () => void;
isArchived?: boolean; isArchived?: boolean;
@@ -358,53 +488,72 @@ function TopBarRight({
isAgentctlReady?: boolean; isAgentctlReady?: boolean;
taskTitle?: string; taskTitle?: string;
}) { }) {
return ( const items: TopbarOverflowItem[] = [];
<div className="flex items-center gap-2 justify-end">
{DEBUG_UI && onToggleDebugOverlay && ( if (!isArchived && workspaceId) {
<Tooltip> items.push({
<TooltipTrigger asChild> id: "quick-chat",
<Button label: "Quick chat",
size="sm" priority: 20,
variant={showDebugOverlay ? "default" : "outline"} content: (
className="cursor-pointer px-2" <TopbarCluster label="Quick chat" className="[&_button]:h-8 [&_button]:text-xs">
onClick={onToggleDebugOverlay}
>
<IconBug className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
{showDebugOverlay ? "Hide Debug Info" : "Show Debug Info"}
</TooltipContent>
</Tooltip>
)}
<GitAheadBehindBadges gitStatus={gitStatus} baseBranch={baseBranch} />
<DocumentControls activeSessionId={activeSessionId ?? null} />
{!isArchived && (
<>
<PortForwardButton
isRemoteExecutor={isRemoteExecutor}
sessionId={activeSessionId}
isAgentctlReady={isAgentctlReady}
/>
<PRTopbarButton />
<IssueTrackerButtons taskId={taskId} workspaceId={workspaceId} taskTitle={taskTitle} />
<QuickChatButton workspaceId={workspaceId} /> <QuickChatButton workspaceId={workspaceId} />
<VcsSplitButton sessionId={activeSessionId ?? null} baseBranch={baseBranch} /> </TopbarCluster>
<LayoutPresetSelector /> ),
<EditorsMenu activeSessionId={activeSessionId ?? null} /> });
</> }
)}
<Tooltip> if (!isArchived) {
<TooltipTrigger asChild> items.push({
<Button size="sm" variant="outline" className="cursor-pointer px-2" asChild> id: "vcs",
<Link href="/settings/general"> label: "Primary version control action",
<IconSettings className="h-4 w-4" /> priority: 100,
</Link> required: true,
</Button> content: (
</TooltipTrigger> <TopbarCluster label="Primary version control action">
<TooltipContent>Settings</TooltipContent> <VcsSplitButton
</Tooltip> sessionId={activeSessionId ?? null}
</div> baseBranch={baseBranch}
buttonSize="lg"
/>
</TopbarCluster>
),
});
}
items.push({
id: "attention",
label: "Task status and attention",
priority: 80,
content: (
<AttentionStatusGroup
taskId={taskId}
activeSessionId={activeSessionId}
isArchived={isArchived}
workspaceId={workspaceId}
isRemoteExecutor={isRemoteExecutor}
isAgentctlReady={isAgentctlReady}
taskTitle={taskTitle}
/>
),
});
items.push({
id: "tools",
label: "Task tools",
priority: 10,
content: (
<TopbarToolsGroup
activeSessionId={activeSessionId}
showDebugOverlay={showDebugOverlay}
onToggleDebugOverlay={onToggleDebugOverlay}
isArchived={isArchived}
/>
),
});
return (
<TopbarActionOverflow items={items} className="justify-self-end [&_button]:whitespace-nowrap" />
); );
} }
@@ -0,0 +1,71 @@
import { describe, expect, it } from "vitest";
import { getHiddenTopbarActionIds } from "./topbar-action-overflow";
const QUICK_CHAT = "quick-chat";
const VCS = "vcs";
const ATTENTION = "attention";
const TOOLS = "tools";
const items = [
{ id: QUICK_CHAT, priority: 20 },
{ id: VCS, priority: 100, required: true },
{ id: ATTENTION, priority: 80 },
{ id: TOOLS, priority: 10 },
];
const widths = new Map([
[QUICK_CHAT, 88],
[VCS, 180],
[ATTENTION, 160],
[TOOLS, 120],
]);
describe("getHiddenTopbarActionIds", () => {
it("keeps every action visible when there is enough width", () => {
expect(
getHiddenTopbarActionIds({
items,
availableWidth: 600,
itemWidths: widths,
gap: 8,
overflowTriggerWidth: 40,
}),
).toEqual([]);
});
it("hides low-priority tools before quick chat or contextual actions", () => {
expect(
getHiddenTopbarActionIds({
items,
availableWidth: 500,
itemWidths: widths,
gap: 8,
overflowTriggerWidth: 40,
}),
).toEqual([TOOLS]);
});
it("hides quick chat before contextual attention actions", () => {
expect(
getHiddenTopbarActionIds({
items,
availableWidth: 410,
itemWidths: widths,
gap: 8,
overflowTriggerWidth: 40,
}),
).toEqual([QUICK_CHAT, TOOLS]);
});
it("keeps required actions inline even when all secondary actions overflow", () => {
expect(
getHiddenTopbarActionIds({
items,
availableWidth: 230,
itemWidths: widths,
gap: 8,
overflowTriggerWidth: 40,
}),
).toEqual([QUICK_CHAT, ATTENTION, TOOLS]);
});
});
@@ -0,0 +1,256 @@
"use client";
import { useCallback, useLayoutEffect, useRef, useState, type ReactNode, type Ref } from "react";
import { IconDots } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@kandev/ui/popover";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { cn } from "@kandev/ui/lib/utils";
export type TopbarOverflowItem = {
id: string;
label: string;
priority: number;
required?: boolean;
content: ReactNode;
};
type TopbarOverflowMetricItem = Pick<TopbarOverflowItem, "id" | "priority" | "required">;
type HiddenTopbarActionArgs = {
items: TopbarOverflowMetricItem[];
availableWidth: number;
itemWidths: ReadonlyMap<string, number>;
gap: number;
overflowTriggerWidth: number;
fallbackItemWidth?: number;
};
const DEFAULT_FALLBACK_ITEM_WIDTH = 88;
const DEFAULT_OVERFLOW_TRIGGER_WIDTH = 40;
function setsMatch(set: Set<string>, values: string[]) {
if (set.size !== values.length) return false;
return values.every((value) => set.has(value));
}
function itemWidth(
item: TopbarOverflowMetricItem,
widths: ReadonlyMap<string, number>,
fallback: number,
) {
return widths.get(item.id) ?? fallback;
}
function totalActionWidth({
items,
hiddenIds,
itemWidths,
gap,
overflowTriggerWidth,
fallbackItemWidth,
}: {
items: TopbarOverflowMetricItem[];
hiddenIds: Set<string>;
itemWidths: ReadonlyMap<string, number>;
gap: number;
overflowTriggerWidth: number;
fallbackItemWidth: number;
}) {
const visibleItems = items.filter((item) => !hiddenIds.has(item.id));
const visibleWidth = visibleItems.reduce(
(total, item) => total + itemWidth(item, itemWidths, fallbackItemWidth),
0,
);
const controlCount = visibleItems.length + (hiddenIds.size > 0 ? 1 : 0);
const gapWidth = Math.max(0, controlCount - 1) * gap;
return visibleWidth + (hiddenIds.size > 0 ? overflowTriggerWidth : 0) + gapWidth;
}
export function getHiddenTopbarActionIds({
items,
availableWidth,
itemWidths,
gap,
overflowTriggerWidth,
fallbackItemWidth = DEFAULT_FALLBACK_ITEM_WIDTH,
}: HiddenTopbarActionArgs): string[] {
const hiddenIds = new Set<string>();
const hideableItems = [...items]
.filter((item) => !item.required)
.sort((a, b) => a.priority - b.priority);
while (
totalActionWidth({
items,
hiddenIds,
itemWidths,
gap,
overflowTriggerWidth,
fallbackItemWidth,
}) > availableWidth
) {
const nextItem = hideableItems.find((item) => !hiddenIds.has(item.id));
if (!nextItem) break;
hiddenIds.add(nextItem.id);
}
return items.filter((item) => hiddenIds.has(item.id)).map((item) => item.id);
}
function readFlexGap(element: HTMLElement) {
const styles = window.getComputedStyle(element);
const gap = Number.parseFloat(styles.columnGap || styles.gap || "0");
return Number.isFinite(gap) ? gap : 0;
}
type TopbarActionOverflowProps = {
items: TopbarOverflowItem[];
className?: string;
};
function measureVisibleItems(
itemRefs: Map<string, HTMLDivElement>,
itemWidths: Map<string, number>,
) {
for (const [id, element] of itemRefs) {
const width = Math.ceil(element.getBoundingClientRect().width);
if (width > 0) itemWidths.set(id, width);
}
}
function useTopbarOverflowState(items: TopbarOverflowItem[]) {
const containerRef = useRef<HTMLDivElement | null>(null);
const overflowTriggerRef = useRef<HTMLButtonElement | null>(null);
const itemRefs = useRef(new Map<string, HTMLDivElement>());
const itemWidths = useRef(new Map<string, number>());
const [hiddenIds, setHiddenIds] = useState<Set<string>>(() => new Set());
const [measuredWidth, setMeasuredWidth] = useState(0);
const registerItem = useCallback(
(id: string) => (element: HTMLDivElement | null) => {
if (element) {
itemRefs.current.set(id, element);
} else {
itemRefs.current.delete(id);
}
},
[],
);
useLayoutEffect(() => {
const element = containerRef.current;
if (!element) return;
const updateWidth = () => setMeasuredWidth(element.clientWidth);
updateWidth();
const observer = new ResizeObserver(updateWidth);
observer.observe(element);
return () => observer.disconnect();
}, []);
useLayoutEffect(() => {
const container = containerRef.current;
if (!container || container.clientWidth <= 0) return;
measureVisibleItems(itemRefs.current, itemWidths.current);
const overflowWidth =
overflowTriggerRef.current?.getBoundingClientRect().width || DEFAULT_OVERFLOW_TRIGGER_WIDTH;
const nextHiddenIds = getHiddenTopbarActionIds({
items,
availableWidth: container.clientWidth,
itemWidths: itemWidths.current,
gap: readFlexGap(container),
overflowTriggerWidth: overflowWidth,
});
setHiddenIds((current) =>
setsMatch(current, nextHiddenIds) ? current : new Set(nextHiddenIds),
);
}, [items, measuredWidth]);
return { containerRef, overflowTriggerRef, registerItem, hiddenIds };
}
function OverflowTrigger({ triggerRef }: { triggerRef: Ref<HTMLButtonElement> }) {
return (
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button
ref={triggerRef}
size="sm"
variant="outline"
className="h-8 cursor-pointer px-2"
aria-label="More top bar actions"
data-testid="topbar-action-overflow-trigger"
>
<IconDots className="h-4 w-4" />
</Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent>More actions</TooltipContent>
</Tooltip>
);
}
function OverflowPopover({
items,
triggerRef,
}: {
items: TopbarOverflowItem[];
triggerRef: Ref<HTMLButtonElement>;
}) {
if (items.length === 0) return null;
return (
<Popover>
<OverflowTrigger triggerRef={triggerRef} />
<PopoverContent
align="end"
className="w-auto max-w-[min(520px,calc(100vw-1rem))] gap-0 rounded-md p-1.5"
>
<div className="flex max-w-[480px] flex-wrap items-center justify-end gap-2">
{items.map((item) => (
<div
key={item.id}
aria-label={item.label}
className="inline-flex shrink-0 items-center [&_button]:h-8 [&_button]:text-xs"
>
{item.content}
</div>
))}
</div>
</PopoverContent>
</Popover>
);
}
export function TopbarActionOverflow({ items, className }: TopbarActionOverflowProps) {
const { containerRef, overflowTriggerRef, registerItem, hiddenIds } =
useTopbarOverflowState(items);
const hiddenItems = items.filter((item) => hiddenIds.has(item.id));
return (
<div
ref={containerRef}
className={cn(
"flex min-w-0 w-full items-center justify-end gap-2 overflow-hidden",
className,
)}
data-testid="topbar-action-overflow"
>
{items.map((item) =>
hiddenIds.has(item.id) ? null : (
<div key={item.id} ref={registerItem(item.id)} className="shrink-0">
{item.content}
</div>
),
)}
<OverflowPopover items={hiddenItems} triggerRef={overflowTriggerRef} />
</div>
);
}
+60 -11
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { memo, useCallback } from "react"; import { memo, useCallback, type ComponentProps, type ReactNode } from "react";
import { import {
IconGitCommit, IconGitCommit,
IconGitPullRequest, IconGitPullRequest,
@@ -24,13 +24,12 @@ import {
DropdownMenuSubContent, DropdownMenuSubContent,
} from "@kandev/ui/dropdown-menu"; } from "@kandev/ui/dropdown-menu";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { cn } from "@kandev/ui/lib/utils";
import { useSessionGit } from "@/hooks/domains/session/use-session-git"; import { useSessionGit } from "@/hooks/domains/session/use-session-git";
import { useGitWithFeedback } from "@/hooks/use-git-with-feedback"; import { useGitWithFeedback } from "@/hooks/use-git-with-feedback";
import { useVcsDialogs } from "@/components/vcs/vcs-dialogs"; import { useVcsDialogs } from "@/components/vcs/vcs-dialogs";
import { useActiveTaskPR } from "@/hooks/domains/github/use-task-pr"; import { useActiveTaskPR } from "@/hooks/domains/github/use-task-pr";
import type { ReactNode } from "react";
function determinePrimaryAction( function determinePrimaryAction(
uncommittedFileCount: number, uncommittedFileCount: number,
aheadCount: number, aheadCount: number,
@@ -72,7 +71,7 @@ function buildPrConfig(aheadCount: number, openPRDialog: () => void): PrimaryBut
return { return {
icon: <IconGitPullRequest className="h-4 w-4" />, icon: <IconGitPullRequest className="h-4 w-4" />,
label: "Create PR", label: "Create PR",
badge: aheadCount > 0 ? aheadCount : null, badge: null,
tooltip: `Create PR (${aheadCount} commit${aheadCount !== 1 ? "s" : ""} ahead)`, tooltip: `Create PR (${aheadCount} commit${aheadCount !== 1 ? "s" : ""} ahead)`,
onClick: openPRDialog, onClick: openPRDialog,
}; };
@@ -82,7 +81,7 @@ function buildPushConfig(aheadCount: number, handlePush: () => void): PrimaryBut
return { return {
icon: <IconCloudUpload className="h-4 w-4" />, icon: <IconCloudUpload className="h-4 w-4" />,
label: "Push", label: "Push",
badge: aheadCount > 0 ? aheadCount : null, badge: null,
tooltip: `Push ${aheadCount} commit${aheadCount !== 1 ? "s" : ""} to remote`, tooltip: `Push ${aheadCount} commit${aheadCount !== 1 ? "s" : ""} to remote`,
onClick: handlePush, onClick: handlePush,
}; };
@@ -96,7 +95,7 @@ function buildRebaseConfig(
return { return {
icon: <IconGitCherryPick className="h-4 w-4" />, icon: <IconGitCherryPick className="h-4 w-4" />,
label: "Rebase", label: "Rebase",
badge: behindCount > 0 ? behindCount : null, badge: null,
tooltip: `Rebase onto ${baseBranch || "origin/main"} (${behindCount} behind)`, tooltip: `Rebase onto ${baseBranch || "origin/main"} (${behindCount} behind)`,
onClick: handleRebase, onClick: handleRebase,
}; };
@@ -131,6 +130,49 @@ function buildPrimaryButtonConfig({
return buildCommitConfig(uncommittedFileCount, openCommitDialog); return buildCommitConfig(uncommittedFileCount, openCommitDialog);
} }
type DivergenceTone = "ahead" | "behind";
const divergenceToneClass: Record<DivergenceTone, string> = {
ahead: "border-emerald-500/40 bg-emerald-500/10 text-emerald-500",
behind: "border-yellow-500/40 bg-yellow-500/10 text-yellow-500",
};
function DivergencePill({
tone,
value,
label,
}: {
tone: DivergenceTone;
value: number;
label: string;
}) {
if (value <= 0) return null;
return (
<span
aria-label={`${value} ${label}`}
className={cn(
"inline-flex h-5 items-center rounded-md border px-1.5 text-[11px] font-semibold leading-none tabular-nums",
divergenceToneClass[tone],
)}
>
{tone === "ahead" ? "↑" : "↓"}
{value}
</span>
);
}
function GitDivergencePills({ ahead, behind }: { ahead: number; behind: number }) {
if (ahead <= 0 && behind <= 0) return null;
return (
<span className="ml-1 inline-flex items-center gap-1">
<DivergencePill tone="ahead" value={ahead} label="commits ahead" />
<DivergencePill tone="behind" value={behind} label="commits behind" />
</span>
);
}
type VcsDropdownItemsProps = { type VcsDropdownItemsProps = {
disabled: boolean; disabled: boolean;
baseBranch?: string; baseBranch?: string;
@@ -219,6 +261,8 @@ function VcsDropdownItems({
type VcsSplitButtonProps = { type VcsSplitButtonProps = {
sessionId: string | null; sessionId: string | null;
baseBranch?: string; baseBranch?: string;
buttonSize?: ComponentProps<typeof Button>["size"];
className?: string;
}; };
function useGitActions(git: ReturnType<typeof useSessionGit>, baseBranch?: string) { function useGitActions(git: ReturnType<typeof useSessionGit>, baseBranch?: string) {
@@ -251,6 +295,8 @@ function useGitActions(git: ReturnType<typeof useSessionGit>, baseBranch?: strin
const VcsSplitButton = memo(function VcsSplitButton({ const VcsSplitButton = memo(function VcsSplitButton({
sessionId, sessionId,
baseBranch, baseBranch,
buttonSize = "sm",
className,
}: VcsSplitButtonProps) { }: VcsSplitButtonProps) {
const git = useSessionGit(sessionId); const git = useSessionGit(sessionId);
const { openCommitDialog, openPRDialog } = useVcsDialogs(); const { openCommitDialog, openPRDialog } = useVcsDialogs();
@@ -285,15 +331,16 @@ const VcsSplitButton = memo(function VcsSplitButton({
handlePush: () => handlePush(false), handlePush: () => handlePush(false),
handleRebase, handleRebase,
}); });
const showDivergencePills = primaryAction !== "commit";
return ( return (
<div className="inline-flex rounded-md border border-border overflow-hidden"> <div className={cn("inline-flex", className)}>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <Button
size="sm" size={buttonSize}
variant="outline" variant="outline"
className="rounded-none border-0 cursor-pointer" className="rounded-r-none border-r-0 cursor-pointer"
onClick={primaryButtonConfig.onClick} onClick={primaryButtonConfig.onClick}
disabled={isDisabled} disabled={isDisabled}
data-testid={`vcs-primary-${primaryAction}`} data-testid={`vcs-primary-${primaryAction}`}
@@ -305,6 +352,7 @@ const VcsSplitButton = memo(function VcsSplitButton({
{primaryButtonConfig.badge} {primaryButtonConfig.badge}
</span> </span>
)} )}
{showDivergencePills && <GitDivergencePills ahead={aheadCount} behind={behindCount} />}
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent>{primaryButtonConfig.tooltip}</TooltipContent> <TooltipContent>{primaryButtonConfig.tooltip}</TooltipContent>
@@ -312,9 +360,10 @@ const VcsSplitButton = memo(function VcsSplitButton({
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button <Button
size="sm" size={buttonSize}
variant="outline" variant="outline"
className="rounded-none border-0 border-l px-2 cursor-pointer" className="-ml-px rounded-l-none px-2 cursor-pointer"
aria-label="Open VCS options"
disabled={isDisabled} disabled={isDisabled}
> >
{isGitLoading ? ( {isGitLoading ? (
+12 -8
View File
@@ -30,19 +30,23 @@ export class WorkflowSettingsPage {
/** Find a workflow card by the name shown in its input field using its current value. */ /** Find a workflow card by the name shown in its input field using its current value. */
async findWorkflowCard(name: string): Promise<Locator> { async findWorkflowCard(name: string): Promise<Locator> {
const cards = this.page.locator('[data-testid^="workflow-card-"]'); const cards = this.page.locator('[data-testid^="workflow-card-"]');
const count = await cards.count(); await expect(cards.first()).toBeVisible();
for (let i = 0; i < count; i++) {
const card = cards.nth(i); const testIds = await cards.evaluateAll((elements) =>
elements
.map((element) => element.getAttribute("data-testid"))
.filter((testId): testId is string => Boolean(testId)),
);
for (const testId of testIds) {
const card = this.page.getByTestId(testId);
const input = card.locator("input").first(); const input = card.locator("input").first();
const value = await input.inputValue(); const value = await input.inputValue({ timeout: 500 }).catch(() => null);
if (value === name) { if (value === name) {
// Return a stable locator using the specific data-testid, not positional nth()
const testId = await card.getAttribute("data-testid");
if (testId) return this.page.getByTestId(testId);
return card; return card;
} }
} }
// Return a locator that won't match so assertions can fail with a good message
return this.page.getByTestId(`workflow-card-not-found-${name}`); return this.page.getByTestId(`workflow-card-not-found-${name}`);
} }
@@ -0,0 +1,79 @@
import { test, expect } from "../../fixtures/test-base";
import type { Page } from "@playwright/test";
type TopbarMetrics = {
workflowRight: number;
actionsLeft: number;
actionsRight: number;
headerRight: number;
};
async function readTopbarMetrics(page: Page) {
return page.evaluate<TopbarMetrics | null>(() => {
const header = document.querySelector('[data-testid="task-topbar"]');
const workflow = document.querySelector('[data-testid="workflow-stepper"]');
const actions = document.querySelector('[data-testid="topbar-action-overflow"]');
if (!header || !workflow || !actions) return null;
const headerRect = header.getBoundingClientRect();
const workflowRect = workflow.getBoundingClientRect();
const actionsRect = actions.getBoundingClientRect();
return {
workflowRight: workflowRect.right,
actionsLeft: actionsRect.left,
actionsRight: actionsRect.right,
headerRight: headerRect.right,
};
});
}
test.describe("Task topbar overflow", () => {
test("keeps contextual PR actions inline before quick chat overflows", async ({
testPage,
apiClient,
seedData,
}) => {
await testPage.setViewportSize({ width: 1100, height: 720 });
const task = await apiClient.createTask(
seedData.workspaceId,
"Responsive topbar PR review task",
{
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
},
);
await apiClient.mockGitHubAssociateTaskPR({
task_id: task.id,
owner: "testorg",
repo: "testrepo",
pr_number: 761,
pr_url: "https://github.com/testorg/testrepo/pull/761",
pr_title: "Responsive topbar overflow",
head_branch: "feature/topbar-overflow",
base_branch: "main",
author_login: "test-user",
additions: 23,
deletions: 3,
});
await testPage.goto(`/t/${task.id}`);
await expect(testPage.getByTestId("task-topbar")).toBeVisible({ timeout: 10_000 });
await expect(testPage.getByTestId("pr-topbar-button")).toBeVisible({ timeout: 15_000 });
await expect(testPage.getByTestId("topbar-action-overflow-trigger")).toBeVisible({
timeout: 10_000,
});
const metrics = await readTopbarMetrics(testPage);
expect(metrics).not.toBeNull();
expect(metrics!.workflowRight).toBeLessThanOrEqual(metrics!.actionsLeft + 1);
expect(metrics!.actionsRight).toBeLessThanOrEqual(metrics!.headerRight + 1);
await testPage.getByTestId("topbar-action-overflow-trigger").click();
await expect(testPage.getByRole("button", { name: "Chat" })).toBeVisible();
});
});
@@ -4,22 +4,27 @@ import { WorkflowSettingsPage } from "../../pages/workflow-settings-page";
test.describe("Workflow sorting", () => { test.describe("Workflow sorting", () => {
test("API reorder persists workflow order", async ({ apiClient, seedData }) => { test("API reorder persists workflow order", async ({ apiClient, seedData }) => {
const { workspaceId } = seedData; const { workspaceId } = seedData;
await apiClient.e2eReset(workspaceId, [seedData.workflowId]);
// Create additional workflows // Create additional workflows
const wfB = await apiClient.createWorkflow(workspaceId, "Workflow B", "simple"); const wfB = await apiClient.createWorkflow(workspaceId, "Workflow B", "simple");
const wfC = await apiClient.createWorkflow(workspaceId, "Workflow C", "simple"); const wfC = await apiClient.createWorkflow(workspaceId, "Workflow C", "simple");
const expectedNames = ["E2E Workflow", "Workflow B", "Workflow C"];
const workflowNames = new Set(expectedNames);
// Verify initial order: sorted by sort_order ASC (0, 1, 2) // Verify initial order: sorted by sort_order ASC (0, 1, 2)
const before = await apiClient.listWorkflows(workspaceId); const before = await apiClient.listWorkflows(workspaceId);
const namesBefore = before.workflows.map((w) => w.name); const namesBefore = before.workflows
expect(namesBefore).toEqual(["E2E Workflow", "Workflow B", "Workflow C"]); .map((w) => w.name)
.filter((name) => workflowNames.has(name));
expect(namesBefore).toEqual(expectedNames);
// Reorder: C, E2E, B // Reorder: C, E2E, B
await apiClient.reorderWorkflows(workspaceId, [wfC.id, seedData.workflowId, wfB.id]); await apiClient.reorderWorkflows(workspaceId, [wfC.id, seedData.workflowId, wfB.id]);
// Verify new order persists // Verify new order persists
const after = await apiClient.listWorkflows(workspaceId); const after = await apiClient.listWorkflows(workspaceId);
const namesAfter = after.workflows.map((w) => w.name); const namesAfter = after.workflows.map((w) => w.name).filter((name) => workflowNames.has(name));
expect(namesAfter).toEqual(["Workflow C", "E2E Workflow", "Workflow B"]); expect(namesAfter).toEqual(["Workflow C", "E2E Workflow", "Workflow B"]);
}); });
@@ -42,7 +47,10 @@ test.describe("Workflow sorting", () => {
// Verify order matches API reorder // Verify order matches API reorder
const order = await page.getWorkflowOrder(); const order = await page.getWorkflowOrder();
expect(order).toEqual(["Extra Workflow", "E2E Workflow"]); expect(order.filter((name) => name === "Extra Workflow" || name === "E2E Workflow")).toEqual([
"Extra Workflow",
"E2E Workflow",
]);
}); });
test("settings page shows drag handles for workflows", async ({ test("settings page shows drag handles for workflows", async ({