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 (
-
+
} />
@@ -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 (
+
+
+
+ {link.label}
+
+
+ );
+ })}
+
+ );
+}
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-mobile.tsx b/apps/web/components/kanban/kanban-header-mobile.tsx
index 3fe53a5254..ff8f9374d8 100644
--- a/apps/web/components/kanban/kanban-header-mobile.tsx
+++ b/apps/web/components/kanban/kanban-header-mobile.tsx
@@ -1,17 +1,16 @@
"use client";
-import Link from "next/link";
import { Button } from "@kandev/ui/button";
-import { TooltipProvider } from "@kandev/ui/tooltip";
import { IconMenu2 } from "@tabler/icons-react";
-import { ReleaseNotesButton } from "../release-notes/release-notes-button";
-import { HealthIndicatorButton } from "../system-health/health-indicator";
+import { PageTopbar } from "@/components/page-topbar";
import { MobileMenuSheet } from "./mobile-menu-sheet";
import { useAppStore } from "@/components/state-provider";
type KanbanHeaderMobileProps = {
workspaceId?: string;
currentPage?: "kanban" | "tasks";
+ title: string;
+ workspaceLabel: string;
searchQuery?: string;
onSearchChange?: (query: string) => void;
isSearchLoading?: boolean;
@@ -24,6 +23,8 @@ type KanbanHeaderMobileProps = {
export function KanbanHeaderMobile({
workspaceId,
currentPage = "kanban",
+ title,
+ workspaceLabel,
searchQuery = "",
onSearchChange,
isSearchLoading = false,
@@ -37,32 +38,23 @@ export function KanbanHeaderMobile({
return (
<>
-
+ }
+ />
>
);
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
setOpen(true)}
className="cursor-pointer"
data-testid="improve-kandev-button"
@@ -140,14 +181,40 @@ function ImproveKandevTopbarButton({ workspaceId }: { workspaceId: string | unde
);
}
+function HomeLeftActions({ workspaceId }: { workspaceId?: string }) {
+ return (
+ <>
+
+
+
+ Stats
+
+
+
+
+ >
+ );
+}
+
+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 && (
-
- )}
-
-
-
- Add task
-
-
-
-
- {showReleaseNotesButton && }
-
-
- setMenuOpen(true)}
- className="cursor-pointer"
- >
-
- Open menu
-
-
-
- );
-}
-
-// 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 && (
+
+ )}
+
+
+ Add task
+
+
+
+
+
+
+
+ setMenuOpen(true)}
+ className="cursor-pointer"
+ >
+
+ Open menu
+
+ >
+ }
+ />
+ );
+}
+
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 (
-
+
+
+
+
+
+
+
+ >
+ }
+ />
);
}
@@ -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 (
+
+ Search
+
+
+ );
+}
+
+function MobileViewSection({
+ viewValue,
+ onViewChange,
+}: {
+ viewValue: string;
+ onViewChange: (value: string) => void;
+}) {
+ return (
+
+ View
+
+
+
+ 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)}>
-
+ Utilities
+ {showReleaseNotesButton && (
+
+
+ Release notes
+
+ )}
+
+
+ {showHealthIndicator ? "Health issues" : "System health"}
+
+
+
Stats
-
-
- onOpenChange(false)}>
-
+
+
+
+
Settings
-
-
+
+
);
}
@@ -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 && (
-
- Search
-
-
- )}
-
-
- View
-
-
-
- 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 (
-