diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000000..667a64c1d7 --- /dev/null +++ b/DESIGN.md @@ -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. diff --git a/PRODUCT.md b/PRODUCT.md new file mode 100644 index 0000000000..8811245b73 --- /dev/null +++ b/PRODUCT.md @@ -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. diff --git a/apps/packages/theme/src/globals.css b/apps/packages/theme/src/globals.css index 7a733acbea..771ac65774 100644 --- a/apps/packages/theme/src/globals.css +++ b/apps/packages/theme/src/globals.css @@ -26,6 +26,21 @@ --color-input: var(--input); --color-border: var(--border); --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: var(--accent); --color-muted-foreground: var(--muted-foreground); @@ -68,6 +83,21 @@ --accent: oklch(0.68 0.18 276); --accent-foreground: oklch(0.98 0 0); --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); --input: oklch(0.922 0 0); --ring: oklch(0.708 0 0); @@ -103,6 +133,21 @@ --accent: oklch(0.62 0.18 276); --accent-foreground: oklch(0.98 0 0); --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%); --input: oklch(1 0 0 / 15%); --ring: oklch(0.556 0 0); @@ -139,9 +184,9 @@ @keyframes kanban-task-pulse { 0%, 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% { - 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); } } diff --git a/apps/packages/ui/src/badge.tsx b/apps/packages/ui/src/badge.tsx index 9ac729eb0e..c2439afa55 100644 --- a/apps/packages/ui/src/badge.tsx +++ b/apps/packages/ui/src/badge.tsx @@ -5,7 +5,7 @@ import { Slot } from "radix-ui"; import { cn } from "./lib/utils"; 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: { variant: { diff --git a/apps/packages/ui/src/button.tsx b/apps/packages/ui/src/button.tsx index ddcc51cc84..b9438bbd62 100644 --- a/apps/packages/ui/src/button.tsx +++ b/apps/packages/ui/src/button.tsx @@ -5,7 +5,7 @@ import { Slot } from "radix-ui"; import { cn } from "./lib/utils"; 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: { variant: { diff --git a/apps/packages/ui/src/card.tsx b/apps/packages/ui/src/card.tsx index e0f8426807..01137bc9f7 100644 --- a/apps/packages/ui/src/card.tsx +++ b/apps/packages/ui/src/card.tsx @@ -12,7 +12,7 @@ function Card({ data-slot="card" data-size={size} 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, )} {...props} @@ -25,7 +25,7 @@ function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
) {
{children} diff --git a/apps/packages/ui/src/input.tsx b/apps/packages/ui/src/input.tsx index 613a5b94c8..b998c58ebf 100644 --- a/apps/packages/ui/src/input.tsx +++ b/apps/packages/ui/src/input.tsx @@ -8,7 +8,7 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) { type={type} data-slot="input" 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, )} {...props} diff --git a/apps/packages/ui/src/select.tsx b/apps/packages/ui/src/select.tsx index 3fd91b032b..566554e309 100644 --- a/apps/packages/ui/src/select.tsx +++ b/apps/packages/ui/src/select.tsx @@ -37,7 +37,7 @@ function SelectTrigger({ data-slot="select-trigger" data-size={size} 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, )} {...props} @@ -62,7 +62,7 @@ function SelectContent({ ) { return ( tr]:last:border-b-0", className)} + className={cn("bg-muted/60 border-t font-medium [&>tr]:last:border-b-0", className)} {...props} /> ); @@ -45,7 +45,7 @@ function TableRow({ className, ...props }: React.ComponentProps<"tr">) { ) { {children} - + ); diff --git a/apps/web/app/dockview-theme.css b/apps/web/app/dockview-theme.css index fba5859cbe..e63dd61cb1 100644 --- a/apps/web/app/dockview-theme.css +++ b/apps/web/app/dockview-theme.css @@ -1,30 +1,23 @@ -/* Dockview theme — Mux-inspired pill tabs */ +/* Dockview workbench chrome */ .dockview-theme-kandev { - /* Overall dockview bg (visible in gaps between groups) — match card to eliminate visible gaps */ --dv-background-color: var(--card); - - /* Each group panel area */ --dv-group-view-background-color: var(--background); - /* Separator / sash */ --dv-separator-border: var(--border); --dv-active-sash-color: var(--primary); --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-height: 40px; --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-color: var(--foreground); - /* Active group, unselected tab — transparent */ --dv-activegroup-hiddenpanel-tab-background-color: transparent; --dv-activegroup-hiddenpanel-tab-color: var(--muted-foreground); - /* Inactive group tabs — more dimmed */ --dv-inactivegroup-visiblepanel-tab-background-color: color-mix( in srgb, var(--muted) 100%, @@ -42,48 +35,38 @@ transparent ); - /* Close icon */ --dv-tab-close-icon: var(--muted-foreground); - - /* Misc */ --dv-tabs-container-scrollbar-color: var(--scrollbar-thumb); --dv-drag-over-background-color: color-mix(in oklab, var(--primary) 10%, transparent); --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 { background: var(--background); border-bottom: 1px solid var(--border); - min-height: 34px; - height: 34px; - padding: 0 6px; + min-height: 40px; + height: 40px; + padding: 7px 5px 5px; align-items: center; } -/* Remove tab divider separators */ .dockview-theme-kandev .dv-tabs-container.dv-horizontal .dv-tab::before { display: none !important; } -/* ── Individual tabs — pill style ── */ .dockview-theme-kandev .dv-tab { padding: 0; - margin: 0 2px; + margin: 0 1px; font-size: 12px; font-weight: 500; min-width: auto; - border-radius: 6px; + border-radius: 5px; border-bottom: none !important; - outline: none !important; + outline: none; transition: background-color 0.15s ease, color 0.15s ease; + cursor: pointer; } .dockview-theme-kandev .dv-tab:hover { @@ -91,28 +74,38 @@ color: var(--foreground); } -/* Active tab — filled pill, no underline */ .dockview-theme-kandev .dv-active-tab { 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 { font-size: 12px; font-weight: 500; - padding: 2px 6px; - gap: 6px; + padding: 0 7px; + gap: 5px; + height: 24px; + line-height: 1; } -/* Close button — show on hover and active */ .dockview-theme-kandev .dv-default-tab .dv-default-tab-action { opacity: 0; - transition: opacity 0.15s ease; - border-radius: 3px; + transition: + opacity 0.15s ease, + background-color 0.15s ease, + color 0.15s ease; + border-radius: 4px; padding: 2px; display: flex; align-items: center; justify-content: center; + cursor: pointer; } .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 { 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, @@ -129,12 +128,10 @@ opacity: 1; } -/* ── Panel content ── */ .dockview-theme-kandev .dv-content-container { background: var(--card); } -/* Strip all chrome from session panel wrappers inside dockview */ .dockview-theme-kandev .dv-content-container .session-panel-content-wrapper { border-radius: 0 !important; box-shadow: none !important; @@ -147,7 +144,6 @@ margin: 0 !important; } -/* ── Sash (resize handle) ── */ .dockview-theme-kandev .dv-resize-container > .sash-container .sash { background: transparent; transition: background-color 0.15s ease; @@ -158,43 +154,49 @@ 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 { display: none !important; } -/* ── Left header actions (after last tab — "+" button) ── */ .dockview-theme-kandev .dv-left-actions-container { display: flex; align-items: center; - padding-left: 4px; + padding-left: 3px; gap: 2px; } -.dv-tab:focus-within:after, -.dv-tab:focus:after { +.dockview-theme-kandev .dv-tab:focus-within::after, +.dockview-theme-kandev .dv-tab:focus::after { outline: none !important; } -/* ── Right header actions ── */ .dockview-theme-kandev .dv-right-actions-container { display: flex; align-items: center; - padding-right: 4px; + padding-right: 3px; 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 { flex: 1; } -/* ── Watermark (empty group) ── */ .dockview-theme-kandev .dv-watermark { background: transparent; } -/* ── Drop target overlay ── */ .dockview-theme-kandev .drop-target-dropzone > .drop-target-selection { border: 2px solid var(--primary); background: color-mix(in oklab, var(--primary) 8%, transparent); diff --git a/apps/web/app/github/github-page-client.tsx b/apps/web/app/github/github-page-client.tsx index 586eafb805..b2fec59c74 100644 --- a/apps/web/app/github/github-page-client.tsx +++ b/apps/web/app/github/github-page-client.tsx @@ -64,7 +64,7 @@ function NotAuthenticatedNotice() { GitHub is not connected. Configure GitHub authentication (gh CLI or a Personal Access Token) in{" "} - + Settings → GitHub {" "} to see your pull requests and issues. diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index 117cc431ac..e7f5549cde 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -34,6 +34,21 @@ --color-input: var(--input); --color-border: var(--border); --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: var(--accent); --color-muted-foreground: var(--muted-foreground); @@ -76,6 +91,21 @@ --accent: oklch(0.68 0.18 276); --accent-foreground: oklch(0.98 0 0); --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); --input: oklch(0.922 0 0); --ring: oklch(0.708 0 0); @@ -95,15 +125,13 @@ --scrollbar-thumb: oklch(0.78 0 0); --scrollbar-thumb-hover: oklch(0.66 0 0); --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; /* #3fb950 - GitHub addition green */ - --git-deletion: 244 112 103; /* #f47067 - GitHub deletion red */ + --git-addition: 63 185 80; + --git-deletion: 244 112 103; } .dark { - /* Grey scale: #181818 bg → #1F1F1F card → #2a2a2a borders → #333 hover */ --background: #181818; --foreground: #d4d4d4; --card: #1f1f1f; @@ -119,6 +147,21 @@ --accent: oklch(0.62 0.18 276); --accent-foreground: #d4d4d4; --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; --input: #2a2a2a; --ring: #555555; @@ -137,11 +180,10 @@ --scrollbar-thumb: rgba(100, 100, 100, 0.5); --scrollbar-thumb-hover: rgba(130, 130, 130, 0.6); --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; /* #3fb950 - GitHub addition green */ - --git-deletion: 244 112 103; /* #f47067 - GitHub deletion red */ + --git-addition: 63 185 80; + --git-deletion: 244 112 103; } @layer base { @@ -162,12 +204,12 @@ *::-webkit-scrollbar-track { background: var(--scrollbar-track); - border-radius: 999px; + border-radius: 8px; } *::-webkit-scrollbar-thumb { background: var(--scrollbar-thumb); - border-radius: 999px; + border-radius: 8px; border: 2px solid transparent; background-clip: padding-box; } @@ -186,7 +228,7 @@ .xterm .xterm-scrollable-element > .scrollbar > .slider { background: var(--scrollbar-thumb) !important; - border-radius: 999px !important; + border-radius: 8px !important; width: 6px !important; left: 2px !important; } @@ -386,16 +428,16 @@ @keyframes chat-input-running-plan { 0%, 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: - 0 0 6px 1px color-mix(in oklab, oklch(0.65 0.2 300) 12%, transparent), - 0 0 15px 3px color-mix(in oklab, oklch(0.65 0.2 300) 6%, transparent); + 0 0 6px 1px color-mix(in oklab, var(--primary) 12%, transparent), + 0 0 15px 3px color-mix(in oklab, var(--primary) 6%, transparent); } 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: - 0 0 20px 4px color-mix(in oklab, oklch(0.65 0.2 300) 20%, transparent), - 0 0 50px 8px color-mix(in oklab, oklch(0.65 0.2 300) 10%, transparent); + 0 0 20px 4px color-mix(in oklab, var(--primary) 20%, transparent), + 0 0 50px 8px color-mix(in oklab, var(--primary) 10%, transparent); } } @@ -452,9 +494,9 @@ .cm-tooltip { border: 1px solid var(--border) !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); - 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; } @@ -479,7 +521,7 @@ border-radius: 6px; cursor: pointer; 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"] { @@ -537,13 +579,13 @@ } .cm-completionIcon-slash { - mask-image: url("data:image/svg+xml;utf8,"); - -webkit-mask-image: url("data:image/svg+xml;utf8,"); + mask-image: url("data:image/svg+xml;utf8,"); + -webkit-mask-image: url("data:image/svg+xml;utf8,"); } .cm-completionIcon-mention { - mask-image: url("data:image/svg+xml;utf8,"); - -webkit-mask-image: url("data:image/svg+xml;utf8,"); + mask-image: url("data:image/svg+xml;utf8,"); + -webkit-mask-image: url("data:image/svg+xml;utf8,"); } .cm-completionIcon-prompt { @@ -594,7 +636,7 @@ /* 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) and ReactMarkdown (chat, PR, changelog, etc.). */ .markdown-body { @@ -673,7 +715,7 @@ margin-bottom: 0.25rem; } -/* Inline code — accent (indigo) color */ +/* Inline code accent color */ .markdown-body :not(pre) > code, .markdown-body code:not(pre code) { background: var(--muted); @@ -749,7 +791,7 @@ 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 code:not(pre code) { background: color-mix(in oklch, var(--primary) 20%, transparent); @@ -780,7 +822,7 @@ /* Code blocks */ .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-radius: 0.375rem; padding: 0.5rem 0.75rem; @@ -831,7 +873,7 @@ .tiptap pre code .hljs-keyword, .tiptap pre code .hljs-selector-tag, .tiptap pre code .hljs-built_in { - color: oklch(0.65 0.2 300); + color: var(--primary); } .tiptap pre code .hljs-string, @@ -848,17 +890,17 @@ .tiptap pre code .hljs-title, .tiptap pre code .hljs-section, .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-tag { - color: oklch(0.65 0.2 20); + color: var(--destructive); } .tiptap pre code .hljs-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, @@ -876,7 +918,7 @@ } .tiptap pre code .hljs-deletion { - color: oklch(0.65 0.2 20); + color: var(--destructive); } .tiptap pre code .hljs-emphasis { @@ -889,28 +931,28 @@ /* Monaco Editor - Comment decorations */ .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 { cursor: pointer !important; - background-color: rgba(99, 102, 241, 0.12) !important; - border-left: 3px solid rgba(99, 102, 241, 0.6) !important; + background-color: color-mix(in oklab, var(--primary) 12%, transparent) !important; + border-left: 3px solid color-mix(in oklab, var(--primary) 58%, transparent) !important; } .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) */ .monaco-comment-bar-icon { cursor: pointer !important; position: relative !important; - background-color: rgba(99, 102, 241, 0.12) !important; - border-left: 3px solid rgba(99, 102, 241, 0.6) !important; + background-color: color-mix(in oklab, var(--primary) 12%, transparent) !important; + border-left: 3px solid color-mix(in oklab, var(--primary) 58%, transparent) !important; } .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 { content: ""; @@ -920,16 +962,16 @@ transform: translate(-50%, -50%); width: 12px; height: 12px; - background: rgba(99, 102, 241, 0.9); - mask-image: url("data:image/svg+xml;utf8,"); - -webkit-mask-image: url("data:image/svg+xml;utf8,"); + background: var(--primary); + mask-image: url("data:image/svg+xml;utf8,"); + -webkit-mask-image: url("data:image/svg+xml;utf8,"); mask-size: contain; -webkit-mask-size: contain; } /* Line number bg tint for commented lines */ .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 @@ -942,7 +984,7 @@ } .tiptap-plan-wrapper:not(.dark) { - background: #ffffff; + background: var(--background); } .tiptap-plan-wrapper .tiptap { @@ -983,29 +1025,29 @@ } .tiptap-plan-wrapper:not(.dark) .tiptap pre { - background: #f5f5f5 !important; + background: var(--muted) !important; } .tiptap-plan-wrapper .tiptap pre code { background: transparent !important; - color: #d4d4d4; + color: var(--foreground); padding: 0 !important; font-size: var(--font-mono-size) !important; font-family: var(--font-mono) !important; } .tiptap-plan-wrapper:not(.dark) .tiptap pre code { - color: #1e1e1e; + color: var(--foreground); } /* Highlight marks */ .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; padding: 1px 0; } -/* Comment highlights — inline mark spans (like Highlight extension) */ +/* Comment highlights, inline mark spans like Highlight extension */ .tiptap-plan-wrapper .comment-highlight { background-color: color-mix(in oklch, var(--accent) 70%, transparent); border-radius: 3px; @@ -1021,7 +1063,7 @@ 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 { position: relative; display: inline-block; @@ -1037,14 +1079,14 @@ left: 2px; width: 11px; height: 11px; - color: rgba(99, 102, 241, 0.8); + color: color-mix(in oklab, var(--primary) 80%, var(--foreground)); cursor: pointer; transition: color 0.15s ease; pointer-events: auto; } .tiptap-plan-wrapper .comment-badge:hover svg { - color: rgba(99, 102, 241, 1); + color: var(--primary); } /* Drag handle */ @@ -1091,7 +1133,7 @@ } .tiptap-plan-wrapper .mermaid-error { - color: #ef4444 !important; + color: var(--destructive) !important; font-size: 0.75rem !important; padding: 0.5rem !important; white-space: pre-wrap !important; @@ -1114,7 +1156,7 @@ } .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 { @@ -1130,7 +1172,7 @@ } .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 { @@ -1151,8 +1193,7 @@ padding: 0.125rem 0.375rem; border-radius: 0.375rem; border: 1px solid oklch(from var(--border) l c h / 0.5); - background: oklch(from var(--background) l c h / 0.8); - backdrop-filter: blur(4px); + background: var(--popover); opacity: 0; transition: opacity 0.15s; z-index: 10; @@ -1249,17 +1290,17 @@ font-size: 11px; font-weight: 700; line-height: 1; - color: #fff; + color: var(--primary-foreground); border-radius: 3px; - background-color: rgb(99 102 241); /* indigo-500 */ + background-color: var(--primary); } /* Monaco Editor - Gutter line selection highlight */ .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 { - 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 */ @@ -1301,48 +1342,23 @@ } /* Pipeline node rotating border for running tasks */ -@property --border-angle { - syntax: ""; - initial-value: 0deg; - inherits: false; -} - -@keyframes node-border-rotate { - to { - --border-angle: 360deg; +@keyframes node-border-pulse { + 0%, + 100% { + 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); + } + 50% { + 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 { position: relative; -} - -.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; + animation: node-border-pulse 2.8s ease-in-out infinite; } .chat-message-list { @@ -1368,17 +1384,17 @@ } .debug { - border: 1px solid red !important; + border: 1px solid var(--destructive) !important; } /* Panel search highlights */ .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; } .search-highlight-current { - background: var(--color-orange-400, #fb923c); - outline: 1px solid var(--color-orange-600, #ea580c); + background: color-mix(in oklab, var(--warning) 56%, transparent); + outline: 1px solid color-mix(in oklab, var(--warning) 78%, var(--border)); border-radius: 2px; } .search-flash { @@ -1386,7 +1402,7 @@ } @keyframes search-flash { 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% { background-color: transparent; diff --git a/apps/web/app/jira/jira-page-client.tsx b/apps/web/app/jira/jira-page-client.tsx index 0f32ed6cd1..03088178da 100644 --- a/apps/web/app/jira/jira-page-client.tsx +++ b/apps/web/app/jira/jira-page-client.tsx @@ -43,7 +43,7 @@ function NotConfiguredNotice({ workspaceId }: { workspaceId?: string }) { Jira is not configured for this workspace.{" "} Configure Jira {" "} diff --git a/apps/web/app/stats/stats-page-client.tsx b/apps/web/app/stats/stats-page-client.tsx index 1675b0629a..f61576c029 100644 --- a/apps/web/app/stats/stats-page-client.tsx +++ b/apps/web/app/stats/stats-page-client.tsx @@ -7,6 +7,7 @@ import { ToggleGroup, ToggleGroupItem } from "@kandev/ui/toggle-group"; import type { StatsResponse } from "@/lib/types/http"; import { useMemo, useState } from "react"; import { useRouter, useSearchParams } from "next/navigation"; +import { IconChartBar } from "@tabler/icons-react"; import { OverviewCards, WorkloadSection, @@ -80,7 +81,7 @@ function getRangeLabel(range: RangeKey): string { function StatsEmptyState({ message }: { message: string }) { return (
- + } />

{message}

@@ -100,6 +101,7 @@ function StatsHeader({ global, range, copied, onRangeChange, onCopy }: StatsHead return ( } subtitle={`${global.total_tasks} tasks · ${global.total_sessions} sessions · ${formatDuration(global.total_duration_ms)}`} actions={ <> @@ -322,7 +324,7 @@ export function StatsPageClient({ stats, error, workspaceId, activeRange }: Stat if (error) return (
- + } />

Error loading stats: {error}

diff --git a/apps/web/components/integrations/integrations-menu.test.ts b/apps/web/components/integrations/integrations-menu.test.ts new file mode 100644 index 0000000000..f7801a1a78 --- /dev/null +++ b/apps/web/components/integrations/integrations-menu.test.ts @@ -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 { + 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(); + }); +}); diff --git a/apps/web/components/integrations/integrations-menu.tsx b/apps/web/components/integrations/integrations-menu.tsx new file mode 100644 index 0000000000..d05cb5c847 --- /dev/null +++ b/apps/web/components/integrations/integrations-menu.tsx @@ -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; + +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 | 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 ( + + + + + + Integrations + {links.map((link) => { + const Icon = INTEGRATION_ICONS[link.id]; + return ( + + + + {link.label} + + + ); + })} + + + ); +} + +export function MobileIntegrationsSection({ + workspaceId, + onNavigate, +}: MobileIntegrationsSectionProps) { + const links = useConfiguredIntegrationLinks(workspaceId); + + if (links.length === 0) return null; + + return ( +
+
Integrations
+ {links.map((link) => { + const Icon = INTEGRATION_ICONS[link.id]; + return ( + + ); + })} +
+ ); +} diff --git a/apps/web/components/kanban-display-dropdown.tsx b/apps/web/components/kanban-display-dropdown.tsx index aa9eb94b2a..8941aefd75 100644 --- a/apps/web/components/kanban-display-dropdown.tsx +++ b/apps/web/components/kanban-display-dropdown.tsx @@ -14,6 +14,11 @@ import { IconAdjustmentsHorizontal } from "@tabler/icons-react"; import { useKanbanDisplaySettings } from "@/hooks/use-kanban-display-settings"; import type { Workspace, Repository } from "@/lib/types/http"; import type { WorkflowsState } from "@/lib/state/slices"; +import type { ComponentProps } from "react"; + +type KanbanDisplayDropdownProps = { + triggerSize?: ComponentProps["size"]; +}; function getRepositoryPlaceholder( repositoriesLoading: boolean, @@ -128,7 +133,7 @@ function RepositorySection({ ); } -export function KanbanDisplayDropdown() { +export function KanbanDisplayDropdown({ triggerSize = "icon" }: KanbanDisplayDropdownProps) { const { workspaces, workflows, @@ -152,7 +157,7 @@ export function KanbanDisplayDropdown() { -
- + } + /> ); diff --git a/apps/web/components/kanban/kanban-header.tsx b/apps/web/components/kanban/kanban-header.tsx index 6861f86bd5..0d8bc8e120 100644 --- a/apps/web/components/kanban/kanban-header.tsx +++ b/apps/web/components/kanban/kanban-header.tsx @@ -4,6 +4,14 @@ import Link from "next/link"; import { useEffect, useRef, useState } from "react"; import { useRouter } from "next/navigation"; import { Button } from "@kandev/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@kandev/ui/dropdown-menu"; import { ToggleGroup, ToggleGroupItem } from "@kandev/ui/toggle-group"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@kandev/ui/tooltip"; import { @@ -14,30 +22,28 @@ import { IconMenu2, IconChartBar, IconTimeline, - IconBrandGithub, - IconTicket, - IconHexagon, IconStethoscope, + IconDots, + IconSparkles, + IconAlertTriangle, } 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 { linkToTask } from "@/lib/links"; +import { IntegrationsMenu } from "@/components/integrations/integrations-menu"; +import { PageTopbar } from "@/components/page-topbar"; import { KanbanDisplayDropdown } from "../kanban-display-dropdown"; -import { ReleaseNotesButton } from "../release-notes/release-notes-button"; import { ReleaseNotesDialog } from "../release-notes/release-notes-dialog"; import { HealthIndicatorButton, HealthIssuesDialog } from "../system-health/health-indicator"; import { TaskSearchInput } from "./task-search-input"; import { QuickChatButton } from "@/components/task/quick-chat-button"; import { KanbanHeaderMobile } from "./kanban-header-mobile"; 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 { useAppStore } from "@/components/state-provider"; import { useKanbanDisplaySettings } from "@/hooks/use-kanban-display-settings"; -import { useGitHubStatus } from "@/hooks/domains/github/use-github-status"; import { useReleaseNotes } from "@/hooks/use-release-notes"; import { useSystemHealthIndicator } from "@/hooks/use-system-health-indicator"; +import type { ComponentProps, RefObject } from "react"; type KanbanHeaderProps = { onCreateTask: () => void; @@ -54,64 +60,99 @@ type ViewToggleItem = { label: string; }; +type HeaderUtilityMenuProps = { + showReleaseNotesButton: boolean; + onOpenReleaseNotes: () => void; + showHealthIndicator: boolean; + onOpenHealthDialog: () => void; + showStatsLink?: boolean; + buttonSize?: ComponentProps["size"]; +}; + const VIEW_TOGGLE_ITEMS: ViewToggleItem[] = [ { value: "kanban", icon: IconLayoutKanban, label: "Kanban" }, { value: "pipeline", icon: IconTimeline, label: "Pipeline" }, { value: "list", icon: IconList, label: "List" }, ]; -function GitHubTopbarButton() { - const { status } = useGitHubStatus(); - if (!status?.authenticated) return null; +const WORKBENCH_TOPBAR_CLASSNAME = "h-10 px-3 py-1"; +const DESKTOP_HEADER_NARROW_PX = 1100; + +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 ( - - - - - GitHub - + + + Utilities + {showReleaseNotesButton && ( + + + Release notes + + )} + + + {showHealthIndicator ? "Health issues" : "System health"} + + {showStatsLink && ( + <> + + + + + Stats + + + + )} + + + + Settings + + + + ); } -function JiraTopbarButton({ workspaceId }: { workspaceId: string | undefined }) { - const available = useJiraAvailable(workspaceId); - if (!available) return null; - return ( - - - - - Jira - - ); -} - -function LinearTopbarButton({ workspaceId }: { workspaceId: string | undefined }) { - const available = useLinearAvailable(workspaceId); - if (!available) return null; - return ( - - - - - Linear - - ); -} - -function ImproveKandevTopbarButton({ workspaceId }: { workspaceId: string | undefined }) { +function ImproveKandevTopbarButton({ + workspaceId, + buttonSize = "icon-lg", +}: { + workspaceId: string | undefined; + buttonSize?: ComponentProps["size"]; +}) { const router = useRouter(); const [open, setOpen] = useState(false); return ( @@ -120,7 +161,7 @@ function ImproveKandevTopbarButton({ workspaceId }: { workspaceId: string | unde + + + + ); +} + +function WorkspaceLeftActions({ workspaceId }: { workspaceId?: string }) { + return ( + <> + + + + ); +} + function ViewToggleGroup({ toggleValue, onValueChange, + size, className, itemClassName, }: { toggleValue: string; onValueChange: (value: string) => void; + size?: ComponentProps["size"]; className?: string; itemClassName?: string; }) { @@ -157,6 +224,7 @@ function ViewToggleGroup({ value={toggleValue} onValueChange={onValueChange} variant="outline" + size={size} className={className} > {VIEW_TOGGLE_ITEMS.map(({ value, icon: Icon, label }) => ( @@ -185,97 +253,9 @@ function getToggleValue(currentPage: string, kanbanViewMode: string | null): str return "kanban"; } -function TabletHeader({ - 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 ( -
-
- - KanDev - - - - - - - -
- {onSearchChange && ( - - )} -
- - - - - {showReleaseNotesButton && } - - - -
-
- ); -} - -// 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): boolean { +function useIsHeaderNarrow(ref: RefObject): boolean { const [isNarrow, setIsNarrow] = useState(false); + useEffect(() => { const el = ref.current; if (!el) return; @@ -285,12 +265,103 @@ function useIsHeaderNarrow(ref: React.RefObject): boolean { observer.observe(el); return () => observer.disconnect(); }, [ref]); + 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 ( + + ) : ( + + ) + } + actionsClassName="gap-2" + actions={ + <> + {onSearchChange && ( + + )} + + + + + + + + + + } + /> + ); +} + function DesktopHeader({ onCreateTask, workspaceId, + title, + workspaceLabel, searchQuery, onSearchChange, isSearchLoading, @@ -303,6 +374,8 @@ function DesktopHeader({ }: { onCreateTask: () => void; workspaceId?: string; + title: string; + workspaceLabel: string; searchQuery: string; onSearchChange?: (query: string) => void; isSearchLoading: boolean; @@ -313,69 +386,70 @@ function DesktopHeader({ showHealthIndicator: boolean; 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(null); const isNarrow = useIsHeaderNarrow(headerRef); - const showSearch = !!onSearchChange && !isNarrow; + const searchInput = onSearchChange ? ( + + ) : null; + const isHome = title === "Home"; + const centerSearch = + isHome && searchInput && !isNarrow ? ( +
{searchInput}
+ ) : null; + const leftActions = isHome ? ( + + ) : ( + + ); + return ( -
-
- - KanDev - -
- - - - - - - - - Stats - - - -
-
- {showSearch && ( -
- -
- )} -
- - - - - - {showReleaseNotesButton && } - - - - - -
-
+ + + + + + + + + } + /> ); } @@ -409,11 +483,14 @@ export function KanbanHeader({ const { isMobile, isTablet } = useResponsiveBreakpoint(); const isMenuOpen = useAppStore((state) => state.mobileKanban.isMenuOpen); const setMenuOpen = useAppStore((state) => state.setMobileKanbanMenuOpen); - const { kanbanViewMode, onViewModeChange } = useKanbanDisplaySettings(); + const { kanbanViewMode, onViewModeChange, workspaces, activeWorkspaceId } = + useKanbanDisplaySettings(); const releaseNotes = useReleaseNotes(); const healthIndicator = useSystemHealthIndicator(); const toggleValue = getToggleValue(currentPage, kanbanViewMode); const handleViewChange = useHeaderViewChange(currentPage, workspaceId, onViewModeChange); + const title = getHeaderTitle(currentPage); + const workspaceLabel = getWorkspaceLabel(workspaces, activeWorkspaceId); const indicatorProps = { showReleaseNotesButton: releaseNotes.showTopbarButton, @@ -430,6 +507,8 @@ export function KanbanHeader({ @@ -440,6 +519,8 @@ export function KanbanHeader({ <> ); @@ -459,6 +541,8 @@ export function KanbanHeader({ return ( void; isSearchLoading?: boolean; + showReleaseNotesButton: boolean; + onOpenReleaseNotes: () => void; + showHealthIndicator: boolean; + onOpenHealthDialog: () => void; }; function getRepositoryPlaceholder(loading: boolean, empty: boolean): string { @@ -30,6 +43,12 @@ function getRepositoryPlaceholder(loading: boolean, empty: boolean): string { 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 = { activeWorkspaceId: string | null; 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 ( +
+ + +
+ ); +} + +function MobileViewSection({ + viewValue, + onViewChange, +}: { + viewValue: string; + onViewChange: (value: string) => void; +}) { + return ( +
+ + + + + Kanban + + + + Pipeline + + + + List + + +
+ ); +} + +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 (
- onOpenChange(false)}> - + )} + + - - onOpenChange(false)}> - + - + +
); } @@ -172,6 +304,10 @@ export function MobileMenuSheet({ searchQuery = "", onSearchChange, isSearchLoading = false, + showReleaseNotesButton, + onOpenReleaseNotes, + showHealthIndicator, + onOpenHealthDialog, }: MobileMenuSheetProps) { const router = useRouter(); const { @@ -188,16 +324,25 @@ export function MobileMenuSheet({ onWorkflowChange, onRepositoryChange, onTogglePreviewOnClick, + kanbanViewMode, + onViewModeChange, } = useKanbanDisplaySettings(); const repositoryValue = allRepositoriesSelected ? "all" : (selectedRepositoryId ?? "all"); + const viewValue = getMobileViewValue(currentPage, kanbanViewMode); const handleViewChange = (value: string) => { - if (value === "list" && currentPage !== "tasks") { - router.push(linkToTasks(workspaceId)); + if (!value) return; + if (value === "list") { + if (currentPage !== "tasks") router.push(linkToTasks(workspaceId)); onOpenChange(false); - } else if (value === "kanban" && currentPage !== "kanban") { - router.push("/"); + } else if (value === "kanban") { + if (currentPage !== "kanban") router.push("/"); + onViewModeChange(""); + onOpenChange(false); + } else if (value === "pipeline") { + if (currentPage !== "kanban") router.push("/"); + onViewModeChange("graph2"); onOpenChange(false); } }; @@ -209,44 +354,12 @@ export function MobileMenuSheet({ Menu
- {onSearchChange && ( -
- - -
- )} - -
- - - - - Kanban - - - - List - - -
+ + - + onOpenChange(false)} + /> + +
diff --git a/apps/web/components/page-topbar.tsx b/apps/web/components/page-topbar.tsx index 1992a643f3..94af6e185a 100644 --- a/apps/web/components/page-topbar.tsx +++ b/apps/web/components/page-topbar.tsx @@ -1,5 +1,16 @@ import Link from "next/link"; +import { forwardRef } from "react"; +import type { ReactNode } from "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 = { /** Page title shown in the breadcrumb */ @@ -7,35 +18,104 @@ type PageTopbarProps = { /** Optional subtitle shown to the right of the title */ subtitle?: string; /** Optional icon rendered before the title */ - icon?: React.ReactNode; + icon?: ReactNode; /** Where the back link navigates to (default: "/") */ 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 */ - 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(function PageTopbar( + { + title, + subtitle, + icon, + backHref = "/", + backLabel = "KanDev", + leading, + center, + leftActions, + actions, + variant = "breadcrumb", + className, + centerClassName, + actionsClassName, + }, + ref, +) { return ( -
- - - KanDev - - › -
- {icon} - {title} - {subtitle && ( - <> - · - {subtitle} - - )} -
- {actions &&
{actions}
} +
+ {leading} + {variant === "root" ? ( +
+ {backLabel} +
+ ) : ( + + + + + + + {backLabel} + + + + + + + {icon} + {title} + {subtitle && ( + <> + · + + {subtitle} + + + )} + + + + + )} + {leftActions && ( +
+ {leftActions} +
+ )} + {center && ( +
+
{center}
+
+ )} + {actions && ( +
+ {actions} +
+ )}
); -} +}); diff --git a/apps/web/components/settings/settings-layout-client.tsx b/apps/web/components/settings/settings-layout-client.tsx index 74f4668154..e80f7d9564 100644 --- a/apps/web/components/settings/settings-layout-client.tsx +++ b/apps/web/components/settings/settings-layout-client.tsx @@ -1,38 +1,30 @@ "use client"; -import { IconArrowLeft } from "@tabler/icons-react"; import { usePathname } from "next/navigation"; -import { Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList } from "@kandev/ui/breadcrumb"; import { SidebarInset, SidebarProvider, SidebarTrigger } from "@kandev/ui/sidebar"; import { TooltipProvider } from "@kandev/ui/tooltip"; +import { PageTopbar } from "@/components/page-topbar"; import { SettingsAppSidebar } from "@/components/settings/settings-app-sidebar"; export function SettingsLayoutClient({ children }: { children: React.ReactNode }) { const pathname = usePathname(); const isAgentDetail = pathname.startsWith("/settings/agents/") && pathname !== "/settings/agents"; - const breadcrumbLabel = isAgentDetail ? "Agents" : "Home"; - const breadcrumbHref = isAgentDetail ? "/settings/agents" : "/"; + const backHref = isAgentDetail ? "/settings/agents" : "/"; + const backLabel = isAgentDetail ? "Agents" : "KanDev"; + const title = isAgentDetail ? "Agent" : "Settings"; return ( -
-
- - - - - - - {breadcrumbLabel} - - - - -
-
+ } + />
{children}
diff --git a/apps/web/components/system-health/health-indicator.tsx b/apps/web/components/system-health/health-indicator.tsx index 4c2ddc9d70..3791779256 100644 --- a/apps/web/components/system-health/health-indicator.tsx +++ b/apps/web/components/system-health/health-indicator.tsx @@ -1,6 +1,7 @@ "use client"; import { useRouter } from "next/navigation"; +import type { ComponentProps } from "react"; import { Button } from "@kandev/ui/button"; import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; import { @@ -17,15 +18,20 @@ import type { HealthIssue } from "@/lib/types/health"; type HealthIndicatorButtonProps = { hasIssues: boolean; onClick: () => void; + size?: ComponentProps["size"]; }; -export function HealthIndicatorButton({ hasIssues, onClick }: HealthIndicatorButtonProps) { +export function HealthIndicatorButton({ + hasIssues, + onClick, + size = "icon", +}: HealthIndicatorButtonProps) { if (!hasIssues) return null; return ( - + ), +})); + +const workflow = { id: "wf-1", name: "Development" }; + +function renderWorkflowSection(effectiveWorkflowId: string | null) { + return render( + {}} + 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(""); + }); +}); diff --git a/apps/web/components/task-create-dialog-form-body.tsx b/apps/web/components/task-create-dialog-form-body.tsx index ad31da1839..aa03565f9d 100644 --- a/apps/web/components/task-create-dialog-form-body.tsx +++ b/apps/web/components/task-create-dialog-form-body.tsx @@ -348,8 +348,21 @@ export const WorkflowSection = memo(function WorkflowSection({ if (!isCreateMode || isTaskStarted) return null; - // Single workflow — show agent override info if any overrides exist - if (workflows.length <= 1) { + if (!effectiveWorkflowId || workflows.length > 1) { + return ( + + ); + } + + // Single selected workflow — show agent override info if any overrides exist + if (workflows.length === 1) { const singleWorkflow = workflows[0]; if (!singleWorkflow) return null; const snapshot = snapshots[singleWorkflow.id]; @@ -386,16 +399,7 @@ export const WorkflowSection = memo(function WorkflowSection({ ); } - return ( - - ); + return null; }); export type DialogPromptSectionProps = { diff --git a/apps/web/components/task-create-dialog-locked-fields.ts b/apps/web/components/task-create-dialog-locked-fields.ts new file mode 100644 index 0000000000..5c305b1439 --- /dev/null +++ b/apps/web/components/task-create-dialog-locked-fields.ts @@ -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]); +} diff --git a/apps/web/components/task-create-dialog-state.test.ts b/apps/web/components/task-create-dialog-state.test.ts new file mode 100644 index 0000000000..f0704ded7c --- /dev/null +++ b/apps/web/components/task-create-dialog-state.test.ts @@ -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"); + }); +}); diff --git a/apps/web/components/task-create-dialog-state.ts b/apps/web/components/task-create-dialog-state.ts index c7f0bfe68b..b88c9159d2 100644 --- a/apps/web/components/task-create-dialog-state.ts +++ b/apps/web/components/task-create-dialog-state.ts @@ -23,16 +23,18 @@ import type { DialogComputedValues, DialogComputedArgs, } from "@/components/task-create-dialog-types"; +import { computePassthroughProfile } from "@/components/task-create-dialog-helpers"; import { - computePassthroughProfile, - computeEffectiveStepId, -} from "@/components/task-create-dialog-helpers"; + computeDialogDefaultStepId, + computeSingleWorkflowFallbackId, +} from "@/components/task-create-dialog-defaults"; export type { StepType, TaskCreateDialogInitialValues, } from "@/components/task-create-dialog-types"; export { autoSelectBranch } from "@/components/task-create-dialog-helpers"; +export { useLockedFieldSync } from "@/components/task-create-dialog-locked-fields"; type FormResetters = { setTaskName: (v: string) => void; @@ -405,32 +407,6 @@ export function useDialogFormState( 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, -) { - 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 { computePassthroughProfile, @@ -454,8 +430,14 @@ export function useDialogComputed({ executors, repositories, workflows, + snapshots, }: 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. const workflowAgentProfileId = (() => { const wfId = effectiveWorkflowId; @@ -471,12 +453,14 @@ export function useDialogComputed({ () => computePassthroughProfile(effectiveAgentProfileId, agentProfiles), [effectiveAgentProfileId, agentProfiles], ); - const effectiveDefaultStepId = computeEffectiveStepId( - fs.selectedWorkflowId, + const effectiveDefaultStepId = computeDialogDefaultStepId({ + selectedWorkflowId: fs.selectedWorkflowId, workflowId, - fs.fetchedSteps, + fetchedSteps: fs.fetchedSteps, defaultStepId, - ); + effectiveWorkflowId, + snapshots, + }); const workspaceDefaults = workspaceId ? workspaces.find((ws: Workspace) => ws.id === workspaceId) : null; @@ -578,6 +562,7 @@ export function useTaskCreateDialogData( executors, repositories, workflows, + snapshots, }); return { workflows, diff --git a/apps/web/components/task-create-dialog-types.ts b/apps/web/components/task-create-dialog-types.ts index 1324232002..cc59b0a58c 100644 --- a/apps/web/components/task-create-dialog-types.ts +++ b/apps/web/components/task-create-dialog-types.ts @@ -7,6 +7,7 @@ import type { Task, } from "@/lib/types/http"; import type { AgentProfileOption } from "@/lib/state/slices"; +import type { WorkflowSnapshotData } from "@/lib/state/slices/kanban/types"; import type { useRepositoryOptions, useBranchOptions, @@ -79,6 +80,7 @@ export type DialogComputedArgs = { executors: Executor[]; repositories: Repository[]; workflows: Array<{ id: string; agent_profile_id?: string }>; + snapshots: Record; }; export type TaskCreateEffectsArgs = { diff --git a/apps/web/components/task/changes-tab.tsx b/apps/web/components/task/changes-tab.tsx index 649576c652..226ec31a68 100644 --- a/apps/web/components/task/changes-tab.tsx +++ b/apps/web/components/task/changes-tab.tsx @@ -118,7 +118,7 @@ export function ChangesTab(props: IDockviewPanelHeaderProps) { return ( - +
{badgeCount > 0 && ( diff --git a/apps/web/components/task/dockview-add-panel-items.tsx b/apps/web/components/task/dockview-add-panel-items.tsx new file mode 100644 index 0000000000..15e72c0287 --- /dev/null +++ b/apps/web/components/task/dockview-add-panel-items.tsx @@ -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 && ( + <> + + + New Agent + + + + + )} + + + Terminal + + addBrowserPanel(undefined, groupId)} + className={MENU_ITEM_CLASS} + > + + Browser + + addVscodePanel()} className={MENU_ITEM_CLASS}> + + VS Code + + {!state.isPassthrough && ( + addPlanPanel({ groupId })} className={MENU_ITEM_CLASS}> + + Plan + + )} + {!state.hasChanges && ( + addChangesPanel(groupId)} className={MENU_ITEM_CLASS}> + + Changes + + )} + {!state.hasFiles && ( + addFilesPanel(groupId)} className={MENU_ITEM_CLASS}> + + Files + + )} + {state.pr && ( + addPRPanel()} className={MENU_ITEM_CLASS}> + + {prPanelLabel(state.pr.pr_number)} + + )} + + + ); +} diff --git a/apps/web/components/task/dockview-header-actions.tsx b/apps/web/components/task/dockview-header-actions.tsx index 0f63cbe7a6..cdc2833535 100644 --- a/apps/web/components/task/dockview-header-actions.tsx +++ b/apps/web/components/task/dockview-header-actions.tsx @@ -4,16 +4,10 @@ import { useCallback, useState } from "react"; import { type IDockviewHeaderActionsProps } from "dockview-react"; import { IconPlus, - IconMessagePlus, IconDeviceDesktop, IconTerminal2, - IconFileText, - IconFolder, - IconGitBranch, - IconGitPullRequest, IconPlayerPlay, IconLayoutSidebarRightCollapse, - IconBrandVscode, } from "@tabler/icons-react"; import { Button } from "@kandev/ui/button"; import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; @@ -21,25 +15,29 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, - DropdownMenuSeparator, DropdownMenuTrigger, } from "@kandev/ui/dropdown-menu"; import { useDockviewStore, performLayoutSwitch } from "@/lib/state/dockview-store"; import { useAppStore, useAppStoreApi } from "@/components/state-provider"; import { useActiveTaskPR } from "@/hooks/domains/github/use-task-pr"; -import { prPanelLabel } from "@/components/github/pr-utils"; import { startProcess } from "@/lib/api"; import { createUserShell } from "@/lib/api/domains/user-shell-api"; import { useRepositoryScripts } from "@/hooks/domains/workspace/use-repository-scripts"; import { replaceTaskUrl } from "@/lib/links"; import type { Task, ProcessInfo } from "@/lib/types/http"; 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 { NewTaskDropdown } from "./new-task-dropdown"; -import { RepositoryScriptsMenuItems, useActiveSessionDevScript } from "./repository-scripts-menu"; -import { SessionReopenMenuItems } from "./session-reopen-menu"; +import { useActiveSessionDevScript } from "./repository-scripts-menu"; 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. */ function mapProcessToStatus(process: ProcessInfo): ProcessStatusEntry { return { @@ -85,94 +83,6 @@ function useLeftHeaderState( }; } -function AddPanelMenuItems({ - groupId, - state, - onNewSession, - onAddTerminal, - onRunScript, - onRunDevScript, -}: { - groupId: string; - state: ReturnType; - 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 && ( - <> - - - New Agent - - - - - )} - - - Terminal - - addBrowserPanel(undefined, groupId)} - className="cursor-pointer text-xs" - > - - Browser - - addVscodePanel()} className="cursor-pointer text-xs"> - - VS Code - - {!state.isPassthrough && ( - addPlanPanel({ groupId })} - className="cursor-pointer text-xs" - > - - Plan - - )} - {!state.hasChanges && ( - addChangesPanel(groupId)} - className="cursor-pointer text-xs" - > - - Changes - - )} - {!state.hasFiles && ( - addFilesPanel(groupId)} className="cursor-pointer text-xs"> - - Files - - )} - {state.pr && ( - addPRPanel()} className="cursor-pointer text-xs"> - - {prPanelLabel(state.pr.pr_number)} - - )} - - - ); -} - export function LeftHeaderActions(props: IDockviewHeaderActionsProps) { const { group, containerApi } = props; const state = useLeftHeaderState(group.id, containerApi); @@ -219,16 +129,18 @@ export function LeftHeaderActions(props: IDockviewHeaderActionsProps) { if (state.isSidebarGroup) return null; return ( -
+
@@ -326,7 +238,7 @@ export function RightHeaderActions(props: IDockviewHeaderActionsProps) { const isTerminalGroup = group.id === rightBottomGroupId; return ( -
+
{isCenterGroup && } {isRightTopGroup && } {isTerminalGroup && } @@ -402,10 +314,11 @@ function RightTopGroupActions() { Hide right panels @@ -452,11 +365,12 @@ function CenterRightActions() { )}
@@ -471,20 +385,46 @@ function TerminalGroupRightActions() { return state.taskSessions.items[sessionId]?.repository_id ?? null; }); const hasDevScript = Boolean(useActiveSessionDevScript()); - 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); + if (scripts.length === 0 && !hasDevScript) return null; + + return ( + <> + + + + ); +} + +type TerminalScriptsDropdownProps = { + scripts: ReturnType["scripts"]; + activeSessionId: string | null; + rightBottomGroupId: string | null; +}; + +function TerminalScriptsDropdown({ + scripts, + activeSessionId, + rightBottomGroupId, +}: TerminalScriptsDropdownProps) { + const addTerminalPanel = useDockviewStore((s) => s.addTerminalPanel); + const handleRunScript = useCallback( async (scriptId: string) => { if (!activeSessionId) return; try { const result = await createUserShell(activeSessionId, { scriptId }); - addTerminalPanel(result.terminalId, rightBottomGroupId); + addTerminalPanel(result.terminalId, rightBottomGroupId ?? undefined); } catch (error) { console.error("Failed to run script:", error); } @@ -492,6 +432,56 @@ function TerminalGroupRightActions() { [activeSessionId, addTerminalPanel, rightBottomGroupId], ); + if (scripts.length === 0) return null; + + return ( + + + + + + {scripts.map((script) => ( + handleRunScript(script.id)} + className={MENU_ITEM_CLASS} + > + + {script.name} + + {script.command} + + + ))} + + + ); +} + +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 () => { if (!activeSessionId) return; addBrowserPanel(); @@ -506,7 +496,7 @@ function TerminalGroupRightActions() { } try { const shell = await createUserShell(activeSessionId); - addTerminalPanel(shell.terminalId, rightBottomGroupId); + addTerminalPanel(shell.terminalId, rightBottomGroupId ?? undefined); } catch { // Terminal creation is best-effort } @@ -519,50 +509,18 @@ function TerminalGroupRightActions() { rightBottomGroupId, ]); - if (scripts.length === 0 && !hasDevScript) return null; + if (!visible) return null; return ( - <> - {scripts.length > 0 && ( - - - - - - {scripts.map((script) => ( - handleRunScript(script.id)} - className="cursor-pointer text-xs" - > - - {script.name} - - {script.command} - - - ))} - - - )} - {hasDevScript && ( - - )} - + ); } diff --git a/apps/web/components/task/new-task-dropdown.tsx b/apps/web/components/task/new-task-dropdown.tsx index c271ab81b3..fe344e9d5a 100644 --- a/apps/web/components/task/new-task-dropdown.tsx +++ b/apps/web/components/task/new-task-dropdown.tsx @@ -55,7 +55,7 @@ export function NewTaskDropdown({ diff --git a/apps/web/components/task/session-tab.tsx b/apps/web/components/task/session-tab.tsx index fc72004c2d..8b0ed7b263 100644 --- a/apps/web/components/task/session-tab.tsx +++ b/apps/web/components/task/session-tab.tsx @@ -272,7 +272,7 @@ export function SessionTab(props: IDockviewPanelHeaderProps) { <>
diff --git a/apps/web/components/task/task-top-bar.tsx b/apps/web/components/task/task-top-bar.tsx index eb46824f7e..17d322d5f9 100644 --- a/apps/web/components/task/task-top-bar.tsx +++ b/apps/web/components/task/task-top-bar.tsx @@ -1,8 +1,8 @@ "use client"; -import { memo, useCallback } from "react"; +import { memo, useCallback, type ReactNode } from "react"; 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 { Breadcrumb, @@ -12,8 +12,15 @@ import { BreadcrumbPage, BreadcrumbSeparator, } 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 { CommitStatBadge } from "@/components/diff-stat"; import { useSessionGit } from "@/hooks/domains/session/use-session-git"; import { EditorsMenu } from "@/components/task/editors-menu"; 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 { RemoteCloudTooltip } from "@/components/task/remote-cloud-tooltip"; 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 { toast } from "sonner"; @@ -64,6 +76,25 @@ type TaskTopBarProps = { 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; +}; + const TaskTopBar = memo(function TaskTopBar({ taskId, activeSessionId, @@ -109,7 +140,10 @@ const TaskTopBar = memo(function TaskTopBar({ ); return ( -
+
- {workflowSteps && workflowSteps.length > 0 && ( - - )} +
+ {workflowSteps && workflowSteps.length > 0 && ( + + )} +
Promise; -}) { +}: TopBarLeftProps) { return (
@@ -262,6 +283,8 @@ function TopBarLeft({ + +
- {ahead > 0 && ( - - - - - - - - {ahead} commit{ahead !== 1 ? "s" : ""} ahead of {compareRef} - - - )} - {behind > 0 && ( - - - - - - - - {behind} commit{behind !== 1 ? "s" : ""} behind {compareRef} - - - )} +
+ {children}
); } -/** 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 ( + + + + + + + + More tools + + + More tools + {showDebugItem && ( + <> + + + {debugLabel} + + + + )} + + + + Settings + + + + + ); +} + +function SettingsButton() { + return ( + + + + + Settings + + ); +} + +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 ( + + + {!isArchived && ( + <> + + + + + )} + + ); +} + +function TopbarToolsGroup({ + activeSessionId, + showDebugOverlay, + onToggleDebugOverlay, + isArchived, +}: { + activeSessionId?: string | null; + showDebugOverlay?: boolean; + onToggleDebugOverlay?: () => void; + isArchived?: boolean; +}) { + const showDebugMenu = DEBUG_UI && onToggleDebugOverlay; + + return ( + + {!isArchived && ( + <> + + + + )} + {showDebugMenu ? ( + + ) : ( + + )} + + ); +} + +/** Right section: primary VCS action, status/attention, tools menu */ function TopBarRight({ taskId, activeSessionId, baseBranch, - gitStatus, showDebugOverlay, onToggleDebugOverlay, isArchived, @@ -349,7 +480,6 @@ function TopBarRight({ taskId?: string | null; activeSessionId?: string | null; baseBranch?: string; - gitStatus: { ahead: number; behind: number }; showDebugOverlay?: boolean; onToggleDebugOverlay?: () => void; isArchived?: boolean; @@ -358,53 +488,72 @@ function TopBarRight({ isAgentctlReady?: boolean; taskTitle?: string; }) { - return ( -
- {DEBUG_UI && onToggleDebugOverlay && ( - - - - - - {showDebugOverlay ? "Hide Debug Info" : "Show Debug Info"} - - - )} - - - {!isArchived && ( - <> - - - + const items: TopbarOverflowItem[] = []; + + if (!isArchived && workspaceId) { + items.push({ + id: "quick-chat", + label: "Quick chat", + priority: 20, + content: ( + - - - - - )} - - - - - Settings - -
+ + ), + }); + } + + if (!isArchived) { + items.push({ + id: "vcs", + label: "Primary version control action", + priority: 100, + required: true, + content: ( + + + + ), + }); + } + + items.push({ + id: "attention", + label: "Task status and attention", + priority: 80, + content: ( + + ), + }); + + items.push({ + id: "tools", + label: "Task tools", + priority: 10, + content: ( + + ), + }); + + return ( + ); } diff --git a/apps/web/components/task/topbar-action-overflow.test.ts b/apps/web/components/task/topbar-action-overflow.test.ts new file mode 100644 index 0000000000..16fe17f073 --- /dev/null +++ b/apps/web/components/task/topbar-action-overflow.test.ts @@ -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]); + }); +}); diff --git a/apps/web/components/task/topbar-action-overflow.tsx b/apps/web/components/task/topbar-action-overflow.tsx new file mode 100644 index 0000000000..bba16a59c6 --- /dev/null +++ b/apps/web/components/task/topbar-action-overflow.tsx @@ -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; + +type HiddenTopbarActionArgs = { + items: TopbarOverflowMetricItem[]; + availableWidth: number; + itemWidths: ReadonlyMap; + gap: number; + overflowTriggerWidth: number; + fallbackItemWidth?: number; +}; + +const DEFAULT_FALLBACK_ITEM_WIDTH = 88; +const DEFAULT_OVERFLOW_TRIGGER_WIDTH = 40; + +function setsMatch(set: Set, values: string[]) { + if (set.size !== values.length) return false; + return values.every((value) => set.has(value)); +} + +function itemWidth( + item: TopbarOverflowMetricItem, + widths: ReadonlyMap, + fallback: number, +) { + return widths.get(item.id) ?? fallback; +} + +function totalActionWidth({ + items, + hiddenIds, + itemWidths, + gap, + overflowTriggerWidth, + fallbackItemWidth, +}: { + items: TopbarOverflowMetricItem[]; + hiddenIds: Set; + itemWidths: ReadonlyMap; + 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(); + 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, + itemWidths: Map, +) { + 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(null); + const overflowTriggerRef = useRef(null); + const itemRefs = useRef(new Map()); + const itemWidths = useRef(new Map()); + const [hiddenIds, setHiddenIds] = useState>(() => 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 }) { + return ( + + + + + + + More actions + + ); +} + +function OverflowPopover({ + items, + triggerRef, +}: { + items: TopbarOverflowItem[]; + triggerRef: Ref; +}) { + if (items.length === 0) return null; + + return ( + + + +
+ {items.map((item) => ( +
+ {item.content} +
+ ))} +
+
+
+ ); +} + +export function TopbarActionOverflow({ items, className }: TopbarActionOverflowProps) { + const { containerRef, overflowTriggerRef, registerItem, hiddenIds } = + useTopbarOverflowState(items); + const hiddenItems = items.filter((item) => hiddenIds.has(item.id)); + + return ( +
+ {items.map((item) => + hiddenIds.has(item.id) ? null : ( +
+ {item.content} +
+ ), + )} + +
+ ); +} diff --git a/apps/web/components/vcs-split-button.tsx b/apps/web/components/vcs-split-button.tsx index 8d45b90c61..f3d52459c1 100644 --- a/apps/web/components/vcs-split-button.tsx +++ b/apps/web/components/vcs-split-button.tsx @@ -1,6 +1,6 @@ "use client"; -import { memo, useCallback } from "react"; +import { memo, useCallback, type ComponentProps, type ReactNode } from "react"; import { IconGitCommit, IconGitPullRequest, @@ -24,13 +24,12 @@ import { DropdownMenuSubContent, } from "@kandev/ui/dropdown-menu"; 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 { useGitWithFeedback } from "@/hooks/use-git-with-feedback"; import { useVcsDialogs } from "@/components/vcs/vcs-dialogs"; import { useActiveTaskPR } from "@/hooks/domains/github/use-task-pr"; -import type { ReactNode } from "react"; - function determinePrimaryAction( uncommittedFileCount: number, aheadCount: number, @@ -72,7 +71,7 @@ function buildPrConfig(aheadCount: number, openPRDialog: () => void): PrimaryBut return { icon: , label: "Create PR", - badge: aheadCount > 0 ? aheadCount : null, + badge: null, tooltip: `Create PR (${aheadCount} commit${aheadCount !== 1 ? "s" : ""} ahead)`, onClick: openPRDialog, }; @@ -82,7 +81,7 @@ function buildPushConfig(aheadCount: number, handlePush: () => void): PrimaryBut return { icon: , label: "Push", - badge: aheadCount > 0 ? aheadCount : null, + badge: null, tooltip: `Push ${aheadCount} commit${aheadCount !== 1 ? "s" : ""} to remote`, onClick: handlePush, }; @@ -96,7 +95,7 @@ function buildRebaseConfig( return { icon: , label: "Rebase", - badge: behindCount > 0 ? behindCount : null, + badge: null, tooltip: `Rebase onto ${baseBranch || "origin/main"} (${behindCount} behind)`, onClick: handleRebase, }; @@ -131,6 +130,49 @@ function buildPrimaryButtonConfig({ return buildCommitConfig(uncommittedFileCount, openCommitDialog); } +type DivergenceTone = "ahead" | "behind"; + +const divergenceToneClass: Record = { + 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 ( + + {tone === "ahead" ? "↑" : "↓"} + {value} + + ); +} + +function GitDivergencePills({ ahead, behind }: { ahead: number; behind: number }) { + if (ahead <= 0 && behind <= 0) return null; + + return ( + + + + + ); +} + type VcsDropdownItemsProps = { disabled: boolean; baseBranch?: string; @@ -219,6 +261,8 @@ function VcsDropdownItems({ type VcsSplitButtonProps = { sessionId: string | null; baseBranch?: string; + buttonSize?: ComponentProps["size"]; + className?: string; }; function useGitActions(git: ReturnType, baseBranch?: string) { @@ -251,6 +295,8 @@ function useGitActions(git: ReturnType, baseBranch?: strin const VcsSplitButton = memo(function VcsSplitButton({ sessionId, baseBranch, + buttonSize = "sm", + className, }: VcsSplitButtonProps) { const git = useSessionGit(sessionId); const { openCommitDialog, openPRDialog } = useVcsDialogs(); @@ -285,15 +331,16 @@ const VcsSplitButton = memo(function VcsSplitButton({ handlePush: () => handlePush(false), handleRebase, }); + const showDivergencePills = primaryAction !== "commit"; return ( -
+
{primaryButtonConfig.tooltip} @@ -312,9 +360,10 @@ const VcsSplitButton = memo(function VcsSplitButton({